From f166fba90e631bda6bb112e8d11a7df5e0121a9a Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Fri, 25 May 2018 08:47:41 -0400 Subject: [PATCH 01/32] Refactored gitstatus.py - Implement possible fix for git 2.17 issue with string case. Suggested a better fix for later. - Implemented python stashed support. Is this in Haskell version? Will have to check. - Reorganized zshrc.sh to be a bit clearer. Few changes such as theme and formatting to align with bash-git-prompt. - Small tidy up to the README. --- README.md | 70 +++++++++++------- gitstatus.py | 199 +++++++++++++++++++++++++++++++++++---------------- zshrc.sh | 167 +++++++++++++++++++++++------------------- 3 files changed, 277 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index 668726cf..df81abc9 100644 --- a/README.md +++ b/README.md @@ -36,31 +36,33 @@ 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` stashed files ### 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 Symbol -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 +70,44 @@ 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` ## Customisation -- 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. + +- 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). + +- To modify the symbols/colors of the theme, simply redefine the variables at bottom of the +the `zshrc.sh` after sourcing. 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%}" +``` **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..38ee2ca6 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -1,65 +1,144 @@ #!/usr/bin/env python -from __future__ import print_function - -# change this symbol to whatever you prefer -prehash = ':' +# -*- coding: utf-8 -*- +""" +Retrieve information about the git repository in CWD. +Invoked by ./zshrc.sh automatically. +""" +import os +import sys from subprocess import Popen, PIPE -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='') +# This symbol appears before hashes when detached +SYM_PREHASH = os.environ.get('ZSH_THEME_GIT_PROMPT_HASH_PREFIX', ':') + + +# TODO: Delete pcmd_error, always check rcode of Popen +# If not 0, raise ProcessError and sys.exit/return in main. +def pcmd(cmd): + """ + Run a simple command and return the output on complete. + + Condition: Command will block until completed. + Use Popen for more control. + + Args: + cmd: The command to run as a list of strings. + + Returns: + out: unicode string of stdout + """ + out, _ = Popen(cmd, stdout=PIPE).communicate() + out = out.decode('utf-8', errors='ignore').strip() + + return out + + +def pcmd_error(cmd): + """ + Run a simple command and return the output and stderr on complete. + + Condition: Command will block until completed. + Use Popen for more control. + + Args: + cmd: The command to run as a list of strings. + + Returns: + out: unicode string of stdout + err: unicode string of stderr + """ + out, err = Popen(cmd, stdout=PIPE, stderr=PIPE).communicate() + out = out.decode('utf-8', errors='ignore').strip() + err = err.decode('utf-8', errors='ignore').strip() + + return out, err + + +def compute_stats(): + """ + Computes and returns the following _numbers_ based on repo in the CWD. + staged files + conflicts + changed + stashed files + untracked + + Returns: + (# staged files, # conflicts, # changed, # stashed, # untracked) + """ + out, err = pcmd_error(['git', 'diff', '--name-status']) + if 'fatal' in err.lower(): + sys.exit(0) + changed_files = [line[0] for line in out.splitlines()] + + out = pcmd(['git', 'diff', '--staged', '--name-status']) + staged_files = [line[0] for line in out.splitlines()] + changed = len(changed_files) - changed_files.count('U') + staged = len(staged_files) - staged_files.count('U') + + out = pcmd(['git', 'status', '--porcelain']) + untracked = len([0 for status in out.splitlines() if status.startswith('??')]) + + conflicts = staged_files.count('U') + stashed = len(pcmd(['git', 'stash', 'list']).splitlines()) + + return staged, conflicts, changed, untracked, stashed + + +def compute_ahead_behind(branch): + """ + Computes how far behind and/or ahead the current branch is from remote. + + Args: + branch: The branch we are tracking remotely. + + Returns: + (# commits behind, # commits ahead) + """ + remote_name = pcmd(['git', 'config', 'branch.%s.remote' % branch]) + if remote_name: + merge_name = pcmd(['git', 'config', 'branch.%s.merge' % branch]) + else: + remote_name = u"origin" + merge_name = u"refs/heads/%s" % branch + + 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) + if revgit.poll(): # fallback to local + revlist = pcmd(['git', 'rev-list', '--left-right', '%s...HEAD' % merge_name]) + else: + revlist = revgit.communicate()[0].decode("utf-8") + + behead = revlist.splitlines() + ahead = len([x for x in behead if x[0] == '>']) + behind = len(behead) - ahead + + return behind, ahead + + +def main(): + """ Main entry point. """ + out, err = pcmd_error(['git', 'symbolic-ref', 'HEAD']) + if 'fatal: not a git repository' in err.lower(): + sys.exit(0) + branch = out[11:] + + remote = 0, 0 + if branch: + remote = compute_ahead_behind(branch) + else: + branch = SYM_PREHASH + pcmd(['git', 'rev-parse', '--short', 'HEAD']) + + values = [str(x) for x in (branch,) + remote + compute_stats()] + sys.stdout.write('\n'.join(values) + '\n') + sys.stdout.flush() + +if __name__ == "__main__": + main() diff --git a/zshrc.sh b/zshrc.sh index d4010c1e..5e5d9169 100644 --- a/zshrc.sh +++ b/zshrc.sh @@ -1,108 +1,127 @@ # 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 + __GIT_CMD=$(ZSH_THEME_GIT_PROMPT_HASH_PREFIX=$ZSH_THEME_GIT_PROMPT_HASH_PREFIX python "$__GIT_PROMPT_DIR/gitstatus.py" 2>/dev/null) + __CURRENT_GIT_STATUS=("${(@f)__GIT_STATUS}") + else + __GIT_CMD=$(git status --porcelain --branch &> /dev/null | $__GIT_PROMPT_DIR/src/.bin/gitstatus) + __CURRENT_GIT_STATUS=("${(@s: :)__GIT_STATUS}") 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] + unset __GIT_CMD + + GIT_BRANCH=$__CURRENT_GIT_STATUS[1] + GIT_BEHIND=$__CURRENT_GIT_STATUS[2] + GIT_AHEAD=$__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_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 [ "$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" + fi + + echo "$STATUS%{${reset_color}%}$ZSH_THEME_GIT_PROMPT_SUFFIX" + 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%}" - +# vim: set filetype=zsh: From 56f03581b796d1edbde2659b26c4e65496c6b3c8 Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Fri, 25 May 2018 13:03:13 -0400 Subject: [PATCH 02/32] FIX: #1 See pcmd_error TODO - All commands checked for bad return. No longer need stderr connected. --- gitstatus.py | 87 ++++++++++++++++++++++------------------------------ 1 file changed, 37 insertions(+), 50 deletions(-) diff --git a/gitstatus.py b/gitstatus.py index 38ee2ca6..23772f68 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -13,46 +13,35 @@ SYM_PREHASH = os.environ.get('ZSH_THEME_GIT_PROMPT_HASH_PREFIX', ':') -# TODO: Delete pcmd_error, always check rcode of Popen -# If not 0, raise ProcessError and sys.exit/return in main. -def pcmd(cmd): +class ProcessError(Exception): """ - Run a simple command and return the output on complete. - - Condition: Command will block until completed. - Use Popen for more control. - - Args: - cmd: The command to run as a list of strings. - - Returns: - out: unicode string of stdout + There was a problem running the command. """ - out, _ = Popen(cmd, stdout=PIPE).communicate() - out = out.decode('utf-8', errors='ignore').strip() + pass - return out - -def pcmd_error(cmd): +def run_cmd(cmd): """ - Run a simple command and return the output and stderr on complete. - - Condition: Command will block until completed. - Use Popen for more control. + Run a simple command and return the output on complete. + Command will block until completed. Use Popen for more control. Args: cmd: The command to run as a list of strings. Returns: out: unicode string of stdout - err: unicode string of stderr + + Raises: + ProcessError - The returncode was not 0. """ - out, err = Popen(cmd, stdout=PIPE, stderr=PIPE).communicate() - out = out.decode('utf-8', errors='ignore').strip() - err = err.decode('utf-8', errors='ignore').strip() + with open(os.devnull, 'w') as devnull: + proc = Popen(cmd, stdout=PIPE, stderr=devnull) - return out, err + out, _ = proc.communicate() + if proc.returncode: + raise ProcessError(proc.returncode, cmd) + + return out.decode('utf-8', errors='ignore').strip() def compute_stats(): @@ -67,21 +56,19 @@ def compute_stats(): Returns: (# staged files, # conflicts, # changed, # stashed, # untracked) """ - out, err = pcmd_error(['git', 'diff', '--name-status']) - if 'fatal' in err.lower(): - sys.exit(0) + out = run_cmd(['git', 'diff', '--name-status']) changed_files = [line[0] for line in out.splitlines()] - out = pcmd(['git', 'diff', '--staged', '--name-status']) + out = run_cmd(['git', 'diff', '--staged', '--name-status']) staged_files = [line[0] for line in out.splitlines()] changed = len(changed_files) - changed_files.count('U') staged = len(staged_files) - staged_files.count('U') - out = pcmd(['git', 'status', '--porcelain']) + out = run_cmd(['git', 'status', '--porcelain']) untracked = len([0 for status in out.splitlines() if status.startswith('??')]) conflicts = staged_files.count('U') - stashed = len(pcmd(['git', 'stash', 'list']).splitlines()) + stashed = len(run_cmd(['git', 'stash', 'list']).splitlines()) return staged, conflicts, changed, untracked, stashed @@ -96,9 +83,9 @@ def compute_ahead_behind(branch): Returns: (# commits behind, # commits ahead) """ - remote_name = pcmd(['git', 'config', 'branch.%s.remote' % branch]) + remote_name = run_cmd(['git', 'config', 'branch.%s.remote' % branch]) if remote_name: - merge_name = pcmd(['git', 'config', 'branch.%s.merge' % branch]) + merge_name = run_cmd(['git', 'config', 'branch.%s.merge' % branch]) else: remote_name = u"origin" merge_name = u"refs/heads/%s" % branch @@ -111,7 +98,7 @@ def compute_ahead_behind(branch): revgit = Popen(['git', 'rev-list', '--left-right', '%s...HEAD' % remote_ref], stdout=PIPE, stderr=PIPE) if revgit.poll(): # fallback to local - revlist = pcmd(['git', 'rev-list', '--left-right', '%s...HEAD' % merge_name]) + revlist = run_cmd(['git', 'rev-list', '--left-right', '%s...HEAD' % merge_name]) else: revlist = revgit.communicate()[0].decode("utf-8") @@ -124,20 +111,20 @@ def compute_ahead_behind(branch): def main(): """ Main entry point. """ - out, err = pcmd_error(['git', 'symbolic-ref', 'HEAD']) - if 'fatal: not a git repository' in err.lower(): - sys.exit(0) - branch = out[11:] - - remote = 0, 0 - if branch: - remote = compute_ahead_behind(branch) - else: - branch = SYM_PREHASH + pcmd(['git', 'rev-parse', '--short', 'HEAD']) - - values = [str(x) for x in (branch,) + remote + compute_stats()] - sys.stdout.write('\n'.join(values) + '\n') - sys.stdout.flush() + try: + branch = run_cmd(['git', 'symbolic-ref', 'HEAD'])[11:] + remote = 0, 0 + + if branch: + remote = compute_ahead_behind(branch) + else: + branch = SYM_PREHASH + run_cmd(['git', 'rev-parse', '--short', 'HEAD']) + + values = [str(x) for x in (branch,) + remote + compute_stats()] + sys.stdout.write('\n'.join(values) + '\n') + sys.stdout.flush() + except ProcessError: + pass if __name__ == "__main__": From 684d61551e7f1a7715efc31b022dabb53e06f578 Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Fri, 25 May 2018 14:28:57 -0400 Subject: [PATCH 03/32] FIX: Prompt was not displaying. - Issue was a simple typo in zshrc.sh - Revert gitstatus.py to printing on line. --- gitstatus.py | 2 +- zshrc.sh | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/gitstatus.py b/gitstatus.py index 23772f68..8b9fcc16 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -121,7 +121,7 @@ def main(): branch = SYM_PREHASH + run_cmd(['git', 'rev-parse', '--short', 'HEAD']) values = [str(x) for x in (branch,) + remote + compute_stats()] - sys.stdout.write('\n'.join(values) + '\n') + sys.stdout.write(' '.join(values)) sys.stdout.flush() except ProcessError: pass diff --git a/zshrc.sh b/zshrc.sh index 5e5d9169..4aa11ffd 100644 --- a/zshrc.sh +++ b/zshrc.sh @@ -25,11 +25,10 @@ update_current_git_vars() { if [ "$GIT_PROMPT_EXECUTABLE" = "python" ]; then __GIT_CMD=$(ZSH_THEME_GIT_PROMPT_HASH_PREFIX=$ZSH_THEME_GIT_PROMPT_HASH_PREFIX python "$__GIT_PROMPT_DIR/gitstatus.py" 2>/dev/null) - __CURRENT_GIT_STATUS=("${(@f)__GIT_STATUS}") else __GIT_CMD=$(git status --porcelain --branch &> /dev/null | $__GIT_PROMPT_DIR/src/.bin/gitstatus) - __CURRENT_GIT_STATUS=("${(@s: :)__GIT_STATUS}") fi + __CURRENT_GIT_STATUS=("${(@s: :)__GIT_CMD}") unset __GIT_CMD GIT_BRANCH=$__CURRENT_GIT_STATUS[1] From 3a5dec44144ba1a779e8aa154f3a0864ab4758fb Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Fri, 25 May 2018 15:41:26 -0400 Subject: [PATCH 04/32] FIX: Regression, unhandled exception. - Really need to get some tests to help cover this. --- gitstatus.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitstatus.py b/gitstatus.py index 8b9fcc16..96a25eb6 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -83,10 +83,10 @@ def compute_ahead_behind(branch): Returns: (# commits behind, # commits ahead) """ - remote_name = run_cmd(['git', 'config', 'branch.%s.remote' % branch]) - if remote_name: + try: + remote_name = run_cmd(['git', 'config', 'branch.%s.remote' % branch]) merge_name = run_cmd(['git', 'config', 'branch.%s.merge' % branch]) - else: + except ProcessError: remote_name = u"origin" merge_name = u"refs/heads/%s" % branch From dca58dc8a90a9dd2113cf6497497c449b30d139f Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Fri, 25 May 2018 16:55:59 -0400 Subject: [PATCH 05/32] Stray unicode flag, not needed. --- gitstatus.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gitstatus.py b/gitstatus.py index 96a25eb6..9e802372 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -87,8 +87,8 @@ def compute_ahead_behind(branch): remote_name = run_cmd(['git', 'config', 'branch.%s.remote' % branch]) merge_name = run_cmd(['git', 'config', 'branch.%s.merge' % branch]) except ProcessError: - remote_name = u"origin" - merge_name = u"refs/heads/%s" % branch + remote_name = "origin" + merge_name = "refs/heads/%s" % branch if remote_name == '.': # local remote_ref = merge_name From 7e86fd86b151fe0fa5d6f97f8bcf74af72d0bc5e Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Fri, 25 May 2018 19:54:40 -0400 Subject: [PATCH 06/32] Ooops, typo. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index df81abc9..83b40934 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ There is now a Haskell implementation as well, which can be four to six times fa 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` -## Customisation +## Customization - Define the variable `ZSH_THEME_GIT_PROMPT_CACHE` in order to enable caching. From e2455934ca873e6b01f77a73011dcd142da1613f Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Sat, 26 May 2018 05:35:26 -0400 Subject: [PATCH 07/32] FIX: #6 Examine compute_ahead_behind, discrepancy - Simple logic problem was the root. - Update gitignore, mainly usual python test dirs and stack's dirs. --- .gitignore | 7 ++++++- gitstatus.py | 38 ++++++++++++++++++++------------------ 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 964f82e7..c837d433 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ -src/.bin \ No newline at end of file +src/.bin +**/__pycache__ +.cache +.pytest_cache/ +**/.stack-work/ +.tox/ diff --git a/gitstatus.py b/gitstatus.py index 9e802372..486f3dc2 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -82,13 +82,12 @@ def compute_ahead_behind(branch): Returns: (# commits behind, # commits ahead) + + Raises: + ProcessError - No tracking information set for branch. """ - try: - remote_name = run_cmd(['git', 'config', 'branch.%s.remote' % branch]) - merge_name = run_cmd(['git', 'config', 'branch.%s.merge' % branch]) - except ProcessError: - remote_name = "origin" - merge_name = "refs/heads/%s" % branch + remote_name = run_cmd(['git', 'config', 'branch.%s.remote' % branch]) + merge_name = run_cmd(['git', 'config', 'branch.%s.merge' % branch]) if remote_name == '.': # local remote_ref = merge_name @@ -111,21 +110,24 @@ def compute_ahead_behind(branch): def main(): """ Main entry point. """ - try: - branch = run_cmd(['git', 'symbolic-ref', 'HEAD'])[11:] - remote = 0, 0 + branch = run_cmd(['git', 'symbolic-ref', 'HEAD'])[11:] + remote = 0, 0 - if branch: + if branch: + try: remote = compute_ahead_behind(branch) - else: - branch = SYM_PREHASH + run_cmd(['git', 'rev-parse', '--short', 'HEAD']) + except ProcessError: + pass + else: + branch = SYM_PREHASH + run_cmd(['git', 'rev-parse', '--short', 'HEAD']) - values = [str(x) for x in (branch,) + remote + compute_stats()] - sys.stdout.write(' '.join(values)) - sys.stdout.flush() - except ProcessError: - pass + values = [str(x) for x in (branch,) + remote + compute_stats()] + sys.stdout.write(' '.join(values)) + sys.stdout.flush() if __name__ == "__main__": - main() + try: + main() + except ProcessError: + pass From ee1c4c3af80453cce9e00a56db8999119f68d449 Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Sat, 26 May 2018 05:47:22 -0400 Subject: [PATCH 08/32] Basic tox configuration to test gitstatus.py - Will check for style issues and run unit tests. --- .pylintrc | 263 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ tox.ini | 40 +++++++++ 2 files changed, 303 insertions(+) create mode 100644 .pylintrc create mode 100644 tox.ini 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/tox.ini b/tox.ini new file mode 100644 index 00000000..fb986d56 --- /dev/null +++ b/tox.ini @@ -0,0 +1,40 @@ +;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} +deps = pytest + +[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: From 7d138e9844f0cf35cf8b79e5470e13626134f553 Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Sat, 26 May 2018 13:10:50 -0400 Subject: [PATCH 09/32] Initial basic set of tests. - FIX: Prompt was not showing when detached HEAD. - BUG: pytest fails from tox, will investigate. --- .gitignore | 1 + gitstatus.py | 40 ++++--- test_gitstatus.py | 269 ++++++++++++++++++++++++++++++++++++++++++++++ tox.ini | 4 +- 4 files changed, 299 insertions(+), 15 deletions(-) create mode 100644 test_gitstatus.py diff --git a/.gitignore b/.gitignore index c837d433..722bccb1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ src/.bin **/__pycache__ .cache .pytest_cache/ +.python-version **/.stack-work/ .tox/ diff --git a/gitstatus.py b/gitstatus.py index 486f3dc2..145aa4e4 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -58,16 +58,15 @@ def compute_stats(): """ out = run_cmd(['git', 'diff', '--name-status']) changed_files = [line[0] for line in out.splitlines()] - out = run_cmd(['git', 'diff', '--staged', '--name-status']) staged_files = [line[0] for line in out.splitlines()] + + conflicts = staged_files.count('U') + staged = len(staged_files) - conflicts changed = len(changed_files) - changed_files.count('U') - staged = len(staged_files) - staged_files.count('U') out = run_cmd(['git', 'status', '--porcelain']) untracked = len([0 for status in out.splitlines() if status.startswith('??')]) - - conflicts = staged_files.count('U') stashed = len(run_cmd(['git', 'stash', 'list']).splitlines()) return staged, conflicts, changed, untracked, stashed @@ -108,18 +107,31 @@ def compute_ahead_behind(branch): return behind, ahead +def get_branch(): + """ + Determine and return the branch of the current git repository. + If we aren't on a branch, return the prefixed hash of the current commit. + """ + proc = Popen(['git', 'symbolic-ref', 'HEAD'], stdout=PIPE, stderr=PIPE) + out, err = proc.communicate() + err = err.decode('utf-8', errors='ignore').strip() + if 'fatal: not a git repository' in err.lower(): + sys.exit(0) + + branch = out.decode('utf-8', errors='ignore').strip()[11:] + if not branch: + branch = SYM_PREHASH + run_cmd(['git', 'rev-parse', '--short', 'HEAD']) + + return branch + + def main(): """ Main entry point. """ - branch = run_cmd(['git', 'symbolic-ref', 'HEAD'])[11:] - remote = 0, 0 - - if branch: - try: - remote = compute_ahead_behind(branch) - except ProcessError: - pass - else: - branch = SYM_PREHASH + run_cmd(['git', 'rev-parse', '--short', 'HEAD']) + branch = get_branch() + try: + remote = compute_ahead_behind(branch) + except ProcessError: + remote = 0, 0 values = [str(x) for x in (branch,) + remote + compute_stats()] sys.stdout.write(' '.join(values)) diff --git a/test_gitstatus.py b/test_gitstatus.py new file mode 100644 index 00000000..26242aeb --- /dev/null +++ b/test_gitstatus.py @@ -0,0 +1,269 @@ +""" +Test module for gitstatus. + +Importantly the fake repos are setup using generators. + next(repo) - repo exists and your CWD is set to it. + next(repo) - repo is cleaned up and you are returned to old CWD +""" +from __future__ import absolute_import, print_function +import os +import re +import shlex +import shutil +import subprocess +import tempfile # TODO: Use tempfile instead of fixed directory name + +import pytest + +import gitstatus + +GIT_STATUS = os.path.join(os.path.dirname(__file__), 'gitstatus.py') +# FIXME: tox -e py fails, py.test works??? + + +def decorate_test(repo_func): + """ + This wrapper simply provides a mechanism to setup the test repository, + then call a func, then always cleanup. + """ + def inner_decorator(func): + def wrapper(): + try: + repo = repo_func() + next(repo) + func() + finally: + try: + next(repo) + except StopIteration: + pass + + return wrapper + return inner_decorator + + +def run_gitstatus(): + """ + Helper to simply run gitstatus in the current directory. + + Returns: + The output of gitstatus.py in the CWD. + """ + return subprocess.check_output(['python', GIT_STATUS]).decode('utf-8', errors='ignore') + + +def git_repo_compute_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 = "/tmp/zsh_git" + folder_up = folder + "_upstream" + cmds = [ + "git init", + "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: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + try: + shutil.rmtree(folder_up) + except (OSError, IOError): + pass + os.makedirs(folder) + 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: + subprocess.check_call(shlex.split(cmd), + stdout=devnull, stderr=subprocess.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + try: + shutil.rmtree(folder_up) + except (OSError, IOError): + pass + os.chdir(cwd) + + +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 = "/tmp/zsh_git" + cmds = [ + "git init", + "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: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + os.makedirs(folder) + 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: + subprocess.check_call(shlex.split(cmd), + stdout=devnull, stderr=subprocess.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + os.chdir(cwd) + + +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 = "/tmp/zsh_git" + cmds = [ + "git init", + "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: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + os.makedirs(folder) + 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: + subprocess.check_call(shlex.split(cmd), + stdout=devnull, stderr=subprocess.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + os.chdir(cwd) + + +# TODO: Use porcelain for status, saves two commands +# conflicts = 0 +# untracked = 0 +# staged = 0 +# changed = 0 +# for line in run_cmd(['git', 'status', '--porcelain']).splitlines(): + # if line[0] in ['A', 'M']: + # changed += 1 + # if line[0] in ['A', 'M']: + # staged += 1 + # if line[0:2] == '??': + # untracked += 1 + + +def test_run_cmd(): + out = gitstatus.run_cmd(["echo", "It works"]) + assert out == "It works" + + +def test_run_cmd_fail(): + with pytest.raises(gitstatus.ProcessError): + gitstatus.run_cmd(["false"]) + + +def test_branch_fatal(): + cwd = os.getcwd() + try: + folder = tempfile.mkdtemp() + os.chdir(folder) + try: + gitstatus.get_branch() + assert False + except SystemExit: + pass + finally: + os.chdir(cwd) + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + + +@decorate_test(git_repo_branch_on_master) +def test_branch_master(): + assert gitstatus.get_branch() == 'master' + + +@decorate_test(git_repo_branch_on_hash) +def test_branch_hash(): + actual_hash = gitstatus.run_cmd(shlex.split('git rev-parse --short HEAD')) + assert gitstatus.get_branch() == gitstatus.SYM_PREHASH + actual_hash + + +@decorate_test(git_repo_compute_stats) +def test_compute_stats_no_conflicts(): + assert run_gitstatus() == 'master 0 0 3 0 1 2 1' diff --git a/tox.ini b/tox.ini index fb986d56..c944f8de 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,9 @@ skipsdist = True [testenv] commands = python -m pytest {posargs} -deps = pytest +deps = + pytest + mock [testenv:flake8] commands = python -m flake8 From 3e6db1a3763ebbd5538ef5dfd58726bc7940d073 Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Sat, 26 May 2018 15:04:18 -0400 Subject: [PATCH 10/32] Working tests for conflicts and remote. --- test_gitstatus.py | 288 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 276 insertions(+), 12 deletions(-) diff --git a/test_gitstatus.py b/test_gitstatus.py index 26242aeb..629b999c 100644 --- a/test_gitstatus.py +++ b/test_gitstatus.py @@ -118,6 +118,73 @@ def git_repo_compute_stats(): os.chdir(cwd) +def git_repo_compute_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 = "/tmp/zsh_git" + folder_up = folder + "_upstream" + cmds = [ + "git init", + "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: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + try: + shutil.rmtree(folder_up) + except (OSError, IOError): + pass + os.makedirs(folder) + 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: + subprocess.check_call(shlex.split(cmd), + stdout=devnull, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError: + pass + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + try: + shutil.rmtree(folder_up) + except (OSError, IOError): + pass + os.chdir(cwd) + + def git_repo_branch_on_hash(): """ Create a fake git repo with the following properties: @@ -211,18 +278,195 @@ def git_repo_branch_on_master(): os.chdir(cwd) -# TODO: Use porcelain for status, saves two commands -# conflicts = 0 -# untracked = 0 -# staged = 0 -# changed = 0 -# for line in run_cmd(['git', 'status', '--porcelain']).splitlines(): - # if line[0] in ['A', 'M']: - # changed += 1 - # if line[0] in ['A', 'M']: - # staged += 1 - # if line[0:2] == '??': - # untracked += 1 +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 = "/tmp/zsh_git" + folder_up = folder + "_upstream" + cmds = [ + "git init", + "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: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + try: + shutil.rmtree(folder_up) + except (OSError, IOError): + pass + os.makedirs(folder) + 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: + subprocess.check_call(shlex.split(cmd), + stdout=devnull, stderr=subprocess.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + try: + shutil.rmtree(folder_up) + except (OSError, IOError): + pass + os.chdir(cwd) + + +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 = "/tmp/zsh_git" + folder_up = folder + "_upstream" + cmds = [ + "git init", + "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: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + try: + shutil.rmtree(folder_up) + except (OSError, IOError): + pass + os.makedirs(folder) + 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: + subprocess.check_call(shlex.split(cmd), + stdout=devnull, stderr=subprocess.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + try: + shutil.rmtree(folder_up) + except (OSError, IOError): + pass + os.chdir(cwd) + + +def git_repo_remote_diverged(): + """ + 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 = "/tmp/zsh_git" + folder_up = folder + "_upstream" + cmds = [ + "git init", + "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", + "first:different third line", + "git add first", + "git commit -m 'different third commit'", + ] + try: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + try: + shutil.rmtree(folder_up) + except (OSError, IOError): + pass + os.makedirs(folder) + 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: + subprocess.check_call(shlex.split(cmd), + stdout=devnull, stderr=subprocess.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + try: + shutil.rmtree(folder_up) + except (OSError, IOError): + pass + os.chdir(cwd) def test_run_cmd(): @@ -267,3 +511,23 @@ def test_branch_hash(): @decorate_test(git_repo_compute_stats) def test_compute_stats_no_conflicts(): assert run_gitstatus() == 'master 0 0 3 0 1 2 1' + + +@decorate_test(git_repo_compute_stats_only_conflicts) +def test_compute_stats_only_conflicts(): + assert run_gitstatus() == 'master 1 1 0 1 1 0 0' + + +@decorate_test(git_repo_remote_ahead) +def test_remote_ahead(): + assert run_gitstatus() == 'master 0 1 0 0 0 0 0' + + +@decorate_test(git_repo_remote_behind) +def test_remote_behind(): + assert run_gitstatus() == 'master 1 0 0 0 0 0 0' + + +@decorate_test(git_repo_remote_diverged) +def test_remote_diverged(): + assert run_gitstatus() == 'master 1 1 0 0 0 0 0' From 326943845a6118ecc55c41f85d423c39377434af Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Sat, 26 May 2018 20:22:53 -0400 Subject: [PATCH 11/32] FIX: Tests work in tox. - Tox env has no configured user information. - Generate work folders from tempfile. --- test_gitstatus.py | 80 +++++++++++++++++++++-------------------------- 1 file changed, 36 insertions(+), 44 deletions(-) diff --git a/test_gitstatus.py b/test_gitstatus.py index 629b999c..fa1e2387 100644 --- a/test_gitstatus.py +++ b/test_gitstatus.py @@ -11,35 +11,13 @@ import shlex import shutil import subprocess -import tempfile # TODO: Use tempfile instead of fixed directory name +import tempfile import pytest import gitstatus GIT_STATUS = os.path.join(os.path.dirname(__file__), 'gitstatus.py') -# FIXME: tox -e py fails, py.test works??? - - -def decorate_test(repo_func): - """ - This wrapper simply provides a mechanism to setup the test repository, - then call a func, then always cleanup. - """ - def inner_decorator(func): - def wrapper(): - try: - repo = repo_func() - next(repo) - func() - finally: - try: - next(repo) - except StopIteration: - pass - - return wrapper - return inner_decorator def run_gitstatus(): @@ -52,6 +30,7 @@ def run_gitstatus(): return subprocess.check_output(['python', GIT_STATUS]).decode('utf-8', errors='ignore') +@pytest.yield_fixture(scope="function") def git_repo_compute_stats(): """ Create a fake git repo with the following properties: @@ -62,10 +41,12 @@ def git_repo_compute_stats(): - 1 stashed change set """ cwd = os.getcwd() - folder = "/tmp/zsh_git" + 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", @@ -118,6 +99,7 @@ def git_repo_compute_stats(): os.chdir(cwd) +@pytest.yield_fixture(scope="function") def git_repo_compute_stats_only_conflicts(): """ Create a fake git repo with the following properties: @@ -125,10 +107,12 @@ def git_repo_compute_stats_only_conflicts(): - edit the same file and create a merge conflict """ cwd = os.getcwd() - folder = "/tmp/zsh_git" + 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'", @@ -185,6 +169,7 @@ def git_repo_compute_stats_only_conflicts(): os.chdir(cwd) +@pytest.yield_fixture(scope="function") def git_repo_branch_on_hash(): """ Create a fake git repo with the following properties: @@ -192,9 +177,11 @@ def git_repo_branch_on_hash(): - yield when on checkout hash """ cwd = os.getcwd() - folder = "/tmp/zsh_git" + 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'", @@ -232,6 +219,7 @@ def git_repo_branch_on_hash(): os.chdir(cwd) +@pytest.yield_fixture(scope="function") def git_repo_branch_on_master(): """ Create a fake git repo with the following properties: @@ -239,9 +227,11 @@ def git_repo_branch_on_master(): - yield when on checkout hash """ cwd = os.getcwd() - folder = "/tmp/zsh_git" + 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'", @@ -278,6 +268,7 @@ def git_repo_branch_on_master(): os.chdir(cwd) +@pytest.yield_fixture(scope="function") def git_repo_remote_ahead(): """ Create a fake git repo with the following properties: @@ -286,10 +277,12 @@ def git_repo_remote_ahead(): - main repo has upstream set and is AHEAD by 1 commit """ cwd = os.getcwd() - folder = "/tmp/zsh_git" + 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'", @@ -340,6 +333,7 @@ def git_repo_remote_ahead(): os.chdir(cwd) +@pytest.yield_fixture(scope="function") def git_repo_remote_behind(): """ Create a fake git repo with the following properties: @@ -348,10 +342,12 @@ def git_repo_remote_behind(): - main repo has upstream set and is BEHIND by 1 commit """ cwd = os.getcwd() - folder = "/tmp/zsh_git" + 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'", @@ -403,6 +399,7 @@ def git_repo_remote_behind(): os.chdir(cwd) +@pytest.yield_fixture(scope="function") def git_repo_remote_diverged(): """ Create a fake git repo with the following properties: @@ -411,10 +408,12 @@ def git_repo_remote_diverged(): - main repo has upstream set and is has diverged by 1 commit each way """ cwd = os.getcwd() - folder = "/tmp/zsh_git" + 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'", @@ -497,37 +496,30 @@ def test_branch_fatal(): pass -@decorate_test(git_repo_branch_on_master) -def test_branch_master(): +def test_branch_master(git_repo_branch_on_master): assert gitstatus.get_branch() == 'master' -@decorate_test(git_repo_branch_on_hash) -def test_branch_hash(): +def test_branch_hash(git_repo_branch_on_hash): actual_hash = gitstatus.run_cmd(shlex.split('git rev-parse --short HEAD')) assert gitstatus.get_branch() == gitstatus.SYM_PREHASH + actual_hash -@decorate_test(git_repo_compute_stats) -def test_compute_stats_no_conflicts(): +def test_compute_stats_no_conflicts(git_repo_compute_stats): assert run_gitstatus() == 'master 0 0 3 0 1 2 1' -@decorate_test(git_repo_compute_stats_only_conflicts) -def test_compute_stats_only_conflicts(): +def test_compute_stats_only_conflicts(git_repo_compute_stats_only_conflicts): assert run_gitstatus() == 'master 1 1 0 1 1 0 0' -@decorate_test(git_repo_remote_ahead) -def test_remote_ahead(): +def test_remote_ahead(git_repo_remote_ahead): assert run_gitstatus() == 'master 0 1 0 0 0 0 0' -@decorate_test(git_repo_remote_behind) -def test_remote_behind(): +def test_remote_behind(git_repo_remote_behind): assert run_gitstatus() == 'master 1 0 0 0 0 0 0' -@decorate_test(git_repo_remote_diverged) -def test_remote_diverged(): +def test_remote_diverged(git_repo_remote_diverged): assert run_gitstatus() == 'master 1 1 0 0 0 0 0' From a877603f5c238cc9dcab501695c9e8ed1216771b Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Sat, 26 May 2018 21:59:14 -0400 Subject: [PATCH 12/32] Implement travis build for python - Test on 2.7, 3.4, 3.5, 3.6. - 3.2, 3.3 should work but getting excessive. - 2.6.x and 3.1.x are unsupported based on code at this time. --- .travis.yml | 68 +++++++++++++++++++++++++++++++++-------------- README.md | 2 +- test_gitstatus.py | 17 +++++++++--- 3 files changed, 62 insertions(+), 25 deletions(-) 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 83b40934..15a4a666 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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)](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. diff --git a/test_gitstatus.py b/test_gitstatus.py index fa1e2387..055f0537 100644 --- a/test_gitstatus.py +++ b/test_gitstatus.py @@ -1,9 +1,8 @@ """ -Test module for gitstatus. +Test module for gitstatus -Importantly the fake repos are setup using generators. - next(repo) - repo exists and your CWD is set to it. - next(repo) - repo is cleaned up and you are returned to old CWD +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 @@ -469,16 +468,19 @@ def git_repo_remote_diverged(): def test_run_cmd(): + """ Simple string to suppress doc warning. """ out = gitstatus.run_cmd(["echo", "It works"]) assert out == "It works" def test_run_cmd_fail(): + """ Simple string to suppress doc warning. """ with pytest.raises(gitstatus.ProcessError): gitstatus.run_cmd(["false"]) def test_branch_fatal(): + """ Simple string to suppress doc warning. """ cwd = os.getcwd() try: folder = tempfile.mkdtemp() @@ -497,29 +499,36 @@ def test_branch_fatal(): def test_branch_master(git_repo_branch_on_master): + """ Simple string to suppress doc warning. """ assert gitstatus.get_branch() == 'master' def test_branch_hash(git_repo_branch_on_hash): + """ Simple string to suppress doc warning. """ actual_hash = gitstatus.run_cmd(shlex.split('git rev-parse --short HEAD')) assert gitstatus.get_branch() == gitstatus.SYM_PREHASH + actual_hash def test_compute_stats_no_conflicts(git_repo_compute_stats): + """ Simple string to suppress doc warning. """ assert run_gitstatus() == 'master 0 0 3 0 1 2 1' def test_compute_stats_only_conflicts(git_repo_compute_stats_only_conflicts): + """ Simple string to suppress doc warning. """ assert run_gitstatus() == 'master 1 1 0 1 1 0 0' def test_remote_ahead(git_repo_remote_ahead): + """ Simple string to suppress doc warning. """ assert run_gitstatus() == 'master 0 1 0 0 0 0 0' def test_remote_behind(git_repo_remote_behind): + """ Simple string to suppress doc warning. """ assert run_gitstatus() == 'master 1 0 0 0 0 0 0' def test_remote_diverged(git_repo_remote_diverged): + """ Simple string to suppress doc warning. """ assert run_gitstatus() == 'master 1 1 0 0 0 0 0' From f78f234ace9094e5a9e50797413ef5874601da7e Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Sun, 27 May 2018 08:33:54 -0400 Subject: [PATCH 13/32] Report test coverage to terminal --- .gitignore | 13 ++++++++----- tox.ini | 3 ++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 722bccb1..a6d30c82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ +.coverage +.python-version + +# Directories src/.bin +.cache/ **/__pycache__ -.cache -.pytest_cache/ -.python-version -**/.stack-work/ -.tox/ +.pytest_cache +**/.stack-work +.tox diff --git a/tox.ini b/tox.ini index c944f8de..7994cb7d 100644 --- a/tox.ini +++ b/tox.ini @@ -8,9 +8,10 @@ skip_missing_interpreters = True skipsdist = True [testenv] -commands = python -m pytest {posargs} +commands = python -m pytest {posargs} --cov=gitstatus --cov-report term-missing deps = pytest + pytest-cov mock [testenv:flake8] From 4766cdf4447a7b872c6c3ae08917d0a720919ddb Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Sun, 27 May 2018 09:09:50 -0400 Subject: [PATCH 14/32] FIX: #3 Show [master L] when branch local only. - Simple suffix to branch name. Will appear if detached is local too. - Travis locked to master build. --- README.md | 8 ++++--- gitstatus.py | 4 +++- test_gitstatus.py | 61 +++++++++++++++++++++++++++++++++++++++++++---- zshrc.sh | 6 +++++ 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 15a4a666..9b02c787 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Informative git prompt for zsh -[![Build Status](https://travis-ci.org/starcraftman/zsh-git-prompt.svg)](https://travis-ci.org/starcraftman/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. @@ -85,12 +85,13 @@ There is now a Haskell implementation as well, which can be four to six times fa ## Customization -- Define the variable `ZSH_THEME_GIT_PROMPT_CACHE` in order to enable caching. +- Define the variable `ZSH_THEME_GIT_PROMPT_CACHE=1` in order to enable caching. - 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). - To modify the symbols/colors of the theme, simply redefine the variables at bottom of the -the `zshrc.sh` after sourcing. These are the defaults: +the `zshrc.sh` after sourcing. This could be in your `~/.zshrc` or sourced elsewhere. +These are the defaults: ```sh ZSH_THEME_GIT_PROMPT_PREFIX="[" @@ -106,6 +107,7 @@ the `zshrc.sh` after sourcing. These are the defaults: 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" ``` **Enjoy!** diff --git a/gitstatus.py b/gitstatus.py index 145aa4e4..dded6e16 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -129,11 +129,13 @@ def main(): """ Main entry point. """ branch = get_branch() try: + local = 0 remote = compute_ahead_behind(branch) except ProcessError: + local = 1 remote = 0, 0 - values = [str(x) for x in (branch,) + remote + compute_stats()] + values = [str(x) for x in (branch,) + remote + compute_stats() + (local,)] sys.stdout.write(' '.join(values)) sys.stdout.flush() diff --git a/test_gitstatus.py b/test_gitstatus.py index 055f0537..78f41231 100644 --- a/test_gitstatus.py +++ b/test_gitstatus.py @@ -267,6 +267,52 @@ def git_repo_branch_on_master(): 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: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + os.makedirs(folder) + 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: + subprocess.check_call(shlex.split(cmd), + stdout=devnull, stderr=subprocess.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + os.chdir(cwd) + + @pytest.yield_fixture(scope="function") def git_repo_remote_ahead(): """ @@ -509,26 +555,31 @@ def test_branch_hash(git_repo_branch_on_hash): assert gitstatus.get_branch() == gitstatus.SYM_PREHASH + actual_hash +def test_branch_local(git_repo_branch_local_only): + """ Simple string to suppress doc warning. """ + assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1' + + def test_compute_stats_no_conflicts(git_repo_compute_stats): """ Simple string to suppress doc warning. """ - assert run_gitstatus() == 'master 0 0 3 0 1 2 1' + assert run_gitstatus() == 'master 0 0 3 0 1 2 1 0' def test_compute_stats_only_conflicts(git_repo_compute_stats_only_conflicts): """ Simple string to suppress doc warning. """ - assert run_gitstatus() == 'master 1 1 0 1 1 0 0' + assert run_gitstatus() == 'master 1 1 0 1 1 0 0 0' def test_remote_ahead(git_repo_remote_ahead): """ Simple string to suppress doc warning. """ - assert run_gitstatus() == 'master 0 1 0 0 0 0 0' + assert run_gitstatus() == 'master 0 1 0 0 0 0 0 0' def test_remote_behind(git_repo_remote_behind): """ Simple string to suppress doc warning. """ - assert run_gitstatus() == 'master 1 0 0 0 0 0 0' + assert run_gitstatus() == 'master 1 0 0 0 0 0 0 0' def test_remote_diverged(git_repo_remote_diverged): """ Simple string to suppress doc warning. """ - assert run_gitstatus() == 'master 1 1 0 0 0 0 0' + assert run_gitstatus() == 'master 1 1 0 0 0 0 0 0' diff --git a/zshrc.sh b/zshrc.sh index 4aa11ffd..6b7e830e 100644 --- a/zshrc.sh +++ b/zshrc.sh @@ -39,12 +39,17 @@ update_current_git_vars() { GIT_CHANGED=$__CURRENT_GIT_STATUS[6] GIT_UNTRACKED=$__CURRENT_GIT_STATUS[7] GIT_STASHED=$__CURRENT_GIT_STATUS[8] + GIT_LOCAL_ONLW=$__CURRENT_GIT_STATUS[9] } git_super_status() { precmd_update_git_vars if [ -n "$__CURRENT_GIT_STATUS" ]; then + if [ "$GIT_LOCAL_ONLW" -ne "0" ]; then + GIT_BRANCH="$GIT_BRANCH%{${reset_color}%}$ZSH_THEME_GIT_PROMPT_LOCAL" + fi + local STATUS="$ZSH_THEME_GIT_PROMPT_PREFIX$ZSH_THEME_GIT_PROMPT_BRANCH$GIT_BRANCH%{${reset_color}%}" local clean=1 @@ -122,5 +127,6 @@ 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" # vim: set filetype=zsh: From 10d8a9c00656335543a299a28e22ccb04890b88f Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Mon, 28 May 2018 10:54:37 -0400 Subject: [PATCH 15/32] FIX: #7 Change compute_stats - Remove all need for commands except for: git status --branch --porcelain - Parse porcelain capture status for repo's staged/unstaged changes. - Parse porcelain capture for difference from remote if set. - Fetch stash number and hash directly from files in .git - Now parsing upstream name, can be set on prompt at later date when changes facilitate. --- gitstatus.py | 161 ++++++++++++++++++++-------------------------- test_gitstatus.py | 36 ++++++----- 2 files changed, 91 insertions(+), 106 deletions(-) diff --git a/gitstatus.py b/gitstatus.py index dded6e16..de72b4d3 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -6,142 +6,123 @@ Invoked by ./zshrc.sh automatically. """ import os +import subprocess as sub import sys -from subprocess import Popen, PIPE # This symbol appears before hashes when detached +SYM_NOUPSTREAM = '..' # Illegal branch name, can't exist SYM_PREHASH = os.environ.get('ZSH_THEME_GIT_PROMPT_HASH_PREFIX', ':') -class ProcessError(Exception): +def compute_stats(lines): """ - There was a problem running the command. - """ - pass - - -def run_cmd(cmd): - """ - Run a simple command and return the output on complete. - Command will block until completed. Use Popen for more control. - - Args: - cmd: The command to run as a list of strings. - - Returns: - out: unicode string of stdout - - Raises: - ProcessError - The returncode was not 0. - """ - with open(os.devnull, 'w') as devnull: - proc = Popen(cmd, stdout=PIPE, stderr=devnull) - - out, _ = proc.communicate() - if proc.returncode: - raise ProcessError(proc.returncode, cmd) - - return out.decode('utf-8', errors='ignore').strip() - - -def compute_stats(): - """ - Computes and returns the following _numbers_ based on repo in the CWD. + Computes and returns the following _numbers_ based on git status porcelain output. staged files conflicts changed - stashed files untracked + stashed files Returns: - (# staged files, # conflicts, # changed, # stashed, # untracked) + (# staged files, # conflicts, # changed, # untracked, # stashed) """ - out = run_cmd(['git', 'diff', '--name-status']) - changed_files = [line[0] for line in out.splitlines()] - out = run_cmd(['git', 'diff', '--staged', '--name-status']) - staged_files = [line[0] for line in out.splitlines()] - - conflicts = staged_files.count('U') - staged = len(staged_files) - conflicts - changed = len(changed_files) - changed_files.count('U') + staged, conflicts, changed, untracked = 0, 0, 0, 0 - out = run_cmd(['git', 'status', '--porcelain']) - untracked = len([0 for status in out.splitlines() if status.startswith('??')]) - stashed = len(run_cmd(['git', 'stash', 'list']).splitlines()) + # 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, stashed + return staged, conflicts, changed, untracked -def compute_ahead_behind(branch): +def parse_ahead_behind(branch): """ - Computes how far behind and/or ahead the current branch is from remote. + Parse how far ahead and/or behind the branch and remote are. Args: - branch: The branch we are tracking remotely. + branch: The branch line of porcelain Returns: (# commits behind, # commits ahead) - - Raises: - ProcessError - No tracking information set for branch. """ - remote_name = run_cmd(['git', 'config', 'branch.%s.remote' % branch]) - merge_name = run_cmd(['git', 'config', 'branch.%s.merge' % branch]) - - 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) - if revgit.poll(): # fallback to local - revlist = run_cmd(['git', 'rev-list', '--left-right', '%s...HEAD' % merge_name]) - else: - revlist = revgit.communicate()[0].decode("utf-8") - - behead = revlist.splitlines() - ahead = len([x for x in behead if x[0] == '>']) - behind = len(behead) - 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 ', '')) + else: + behind = int(part.replace('behind ', '')) return behind, ahead def get_branch(): """ - Determine and return the branch of the current git repository. - If we aren't on a branch, return the prefixed hash of the current commit. + 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. + Capture status for later processing. + + 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. + lines: The full capture of git status for later processing. List of lines. + + Raises: + SystemExit - No git repository in CWD. """ - proc = Popen(['git', 'symbolic-ref', 'HEAD'], stdout=PIPE, stderr=PIPE) + 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() if 'fatal: not a git repository' in err.lower(): sys.exit(0) - branch = out.decode('utf-8', errors='ignore').strip()[11:] - if not branch: - branch = SYM_PREHASH + run_cmd(['git', 'rev-parse', '--short', 'HEAD']) + lines = out.decode('utf-8', errors='ignore').splitlines() + branch = lines[0][3:] + if ' [' in branch: + branch = branch[:branch.rindex(' [')] + + upstream = SYM_NOUPSTREAM + local = 1 + if 'no branch' in branch: + with open('.git/HEAD') as fin: + branch = SYM_PREHASH + fin.read().strip()[:7] + local = 0 + elif '...' in branch: + branch, upstream = branch.split('...') + local = 0 - return branch + return branch, upstream, local, lines def main(): """ Main entry point. """ - branch = get_branch() + # TODO: Use upstream and update tests + branch, _, local, lines = get_branch() + remote = parse_ahead_behind(lines[0]) + stats = compute_stats(lines[1:]) + try: - local = 0 - remote = compute_ahead_behind(branch) - except ProcessError: - local = 1 - remote = 0, 0 + with open('.git/logs/refs/stash') as fin: + stashed = len(fin.readlines()) + except IOError: + stashed = 0 - values = [str(x) for x in (branch,) + remote + compute_stats() + (local,)] + values = [str(x) for x in (branch,) + remote + stats + (stashed, local)] sys.stdout.write(' '.join(values)) sys.stdout.flush() if __name__ == "__main__": - try: - main() - except ProcessError: - pass + main() diff --git a/test_gitstatus.py b/test_gitstatus.py index 78f41231..dff56e3e 100644 --- a/test_gitstatus.py +++ b/test_gitstatus.py @@ -513,18 +513,6 @@ def git_repo_remote_diverged(): os.chdir(cwd) -def test_run_cmd(): - """ Simple string to suppress doc warning. """ - out = gitstatus.run_cmd(["echo", "It works"]) - assert out == "It works" - - -def test_run_cmd_fail(): - """ Simple string to suppress doc warning. """ - with pytest.raises(gitstatus.ProcessError): - gitstatus.run_cmd(["false"]) - - def test_branch_fatal(): """ Simple string to suppress doc warning. """ cwd = os.getcwd() @@ -546,13 +534,14 @@ def test_branch_fatal(): def test_branch_master(git_repo_branch_on_master): """ Simple string to suppress doc warning. """ - assert gitstatus.get_branch() == 'master' + assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1' def test_branch_hash(git_repo_branch_on_hash): """ Simple string to suppress doc warning. """ - actual_hash = gitstatus.run_cmd(shlex.split('git rev-parse --short HEAD')) - assert gitstatus.get_branch() == gitstatus.SYM_PREHASH + actual_hash + actual_hash = subprocess.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'.format(actual_hash) def test_branch_local(git_repo_branch_local_only): @@ -567,7 +556,7 @@ def test_compute_stats_no_conflicts(git_repo_compute_stats): def test_compute_stats_only_conflicts(git_repo_compute_stats_only_conflicts): """ Simple string to suppress doc warning. """ - assert run_gitstatus() == 'master 1 1 0 1 1 0 0 0' + assert run_gitstatus() == 'master 1 1 0 1 0 0 0 0' def test_remote_ahead(git_repo_remote_ahead): @@ -583,3 +572,18 @@ def test_remote_behind(git_repo_remote_behind): def test_remote_diverged(git_repo_remote_diverged): """ Simple string to suppress doc warning. """ assert run_gitstatus() == 'master 1 1 0 0 0 0 0 0' + + +def test_parse_ahead_behind_only_ahead(): + """ Simple string to suppress doc warning. """ + assert gitstatus.parse_ahead_behind("## master...up/master [ahead 1]") == (0, 1) + + +def test_parse_ahead_behind_only_behind(): + """ Simple string to suppress doc warning. """ + assert gitstatus.parse_ahead_behind("## master...up/master [behind 1]") == (1, 0) + + +def test_parse_ahead_behind_both(): + """ Simple string to suppress doc warning. """ + assert gitstatus.parse_ahead_behind("## master...up/master [ahead 1, behind 1]") == (1, 1) From e93f615f244892eb106084b7901b30a4a83d2001 Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Mon, 28 May 2018 11:28:22 -0400 Subject: [PATCH 16/32] Better separation of funcs. - Notes for future improvements. --- gitstatus.py | 32 ++++++++++++++++---------------- test_gitstatus.py | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/gitstatus.py b/gitstatus.py index de72b4d3..16e3cf0b 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -65,31 +65,22 @@ def parse_ahead_behind(branch): return behind, ahead -def get_branch(): +def parse_branch(branch): """ 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. Capture status for later processing. + Args: + branch: The branch line of porcelain + 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. - lines: The full capture of git status for later processing. List of lines. - - Raises: - SystemExit - No git repository in CWD. """ - 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() - if 'fatal: not a git repository' in err.lower(): - sys.exit(0) - - lines = out.decode('utf-8', errors='ignore').splitlines() - branch = lines[0][3:] + branch = branch[3:] if ' [' in branch: branch = branch[:branch.rindex(' [')] @@ -103,13 +94,22 @@ def get_branch(): branch, upstream = branch.split('...') local = 0 - return branch, upstream, local, lines + return branch, upstream, local def main(): """ Main entry point. """ + # TODO: Read from sys.stdin, execute ONLY if not sent + 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() + if 'fatal: not a git repository' in err.lower(): + sys.exit(0) + + lines = out.decode('utf-8', errors='ignore').splitlines() # TODO: Use upstream and update tests - branch, _, local, lines = get_branch() + branch, _, local = parse_branch(lines[0]) remote = parse_ahead_behind(lines[0]) stats = compute_stats(lines[1:]) diff --git a/test_gitstatus.py b/test_gitstatus.py index dff56e3e..9d07a10f 100644 --- a/test_gitstatus.py +++ b/test_gitstatus.py @@ -520,7 +520,7 @@ def test_branch_fatal(): folder = tempfile.mkdtemp() os.chdir(folder) try: - gitstatus.get_branch() + gitstatus.main() assert False except SystemExit: pass From fba5070393db81f68b92e22b3e490db4576c536e Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Mon, 28 May 2018 13:58:52 -0400 Subject: [PATCH 17/32] FIX: #12 Speedup by taking input from STDIN - When no stdin detected, fallback to running own command. --- gitstatus.py | 64 +++++++++++++++++++++++++++++++---------------- test_gitstatus.py | 18 ++++++++++--- 2 files changed, 57 insertions(+), 25 deletions(-) diff --git a/gitstatus.py b/gitstatus.py index 16e3cf0b..0499a726 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -9,22 +9,22 @@ import subprocess as sub import sys +# Denotes no upstream set, impossible branch name per git ref spec +SYM_NOUPSTREAM = '..' # This symbol appears before hashes when detached -SYM_NOUPSTREAM = '..' # Illegal branch name, can't exist SYM_PREHASH = os.environ.get('ZSH_THEME_GIT_PROMPT_HASH_PREFIX', ':') -def compute_stats(lines): +def parse_stats(lines): """ - Computes and returns the following _numbers_ based on git status porcelain output. - staged files - conflicts - changed - untracked - stashed files + Computes and returns the following _numbers_ describing the current state. + number of staged files + number of conflicts + number of changed files + number of untracked files Returns: - (# staged files, # conflicts, # changed, # untracked, # stashed) + (# staged, # conflicts, # changed, # untracked) """ staged, conflicts, changed, untracked = 0, 0, 0, 0 @@ -97,21 +97,17 @@ def parse_branch(branch): return branch, upstream, local -def main(): - """ Main entry point. """ - # TODO: Read from sys.stdin, execute ONLY if not sent - 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() - if 'fatal: not a git repository' in err.lower(): - sys.exit(0) +def current_git_status(lines): + """ + Parse git status procelain output and return the formatted text that + represents the current status of the respoistory. - lines = out.decode('utf-8', errors='ignore').splitlines() + Returns: The formatted message representing the git repository. + """ # TODO: Use upstream and update tests branch, _, local = parse_branch(lines[0]) remote = parse_ahead_behind(lines[0]) - stats = compute_stats(lines[1:]) + stats = parse_stats(lines[1:]) try: with open('.git/logs/refs/stash') as fin: @@ -120,7 +116,33 @@ def main(): stashed = 0 values = [str(x) for x in (branch,) + remote + stats + (stashed, local)] - sys.stdout.write(' '.join(values)) + + 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 = 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 'fatal: not a git repository' in err.lower(): + sys.exit(0) + + sys.stdout.write(current_git_status(lines)) sys.stdout.flush() diff --git a/test_gitstatus.py b/test_gitstatus.py index 9d07a10f..37caf654 100644 --- a/test_gitstatus.py +++ b/test_gitstatus.py @@ -30,7 +30,7 @@ def run_gitstatus(): @pytest.yield_fixture(scope="function") -def git_repo_compute_stats(): +def git_repo_parse_stats(): """ Create a fake git repo with the following properties: - upstream set to another local git repo @@ -99,7 +99,7 @@ def git_repo_compute_stats(): @pytest.yield_fixture(scope="function") -def git_repo_compute_stats_only_conflicts(): +def git_repo_parse_stats_only_conflicts(): """ Create a fake git repo with the following properties: - upstream set to another local git repo @@ -549,12 +549,12 @@ def test_branch_local(git_repo_branch_local_only): assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1' -def test_compute_stats_no_conflicts(git_repo_compute_stats): +def test_parse_stats_no_conflicts(git_repo_parse_stats): """ Simple string to suppress doc warning. """ assert run_gitstatus() == 'master 0 0 3 0 1 2 1 0' -def test_compute_stats_only_conflicts(git_repo_compute_stats_only_conflicts): +def test_parse_stats_only_conflicts(git_repo_parse_stats_only_conflicts): """ Simple string to suppress doc warning. """ assert run_gitstatus() == 'master 1 1 0 1 0 0 0 0' @@ -587,3 +587,13 @@ def test_parse_ahead_behind_only_behind(): def test_parse_ahead_behind_both(): """ Simple string to suppress doc warning. """ assert gitstatus.parse_ahead_behind("## master...up/master [ahead 1, behind 1]") == (1, 1) + + +def test_main_stdin(git_repo_parse_stats): + """ Simple string to suppress doc warning. """ + out = subprocess.check_output(['git', 'status', '--branch', '--porcelain']) + with tempfile.TemporaryFile() as finput: + finput.write(out) + finput.seek(0) + out = subprocess.check_output(['python', GIT_STATUS], stdin=finput) + assert out.decode('utf-8', errors='ignore') == 'master 0 0 3 0 1 2 1 0' From 6fda93eed5f88e366149b55f1bc448831e0df4df Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Mon, 28 May 2018 14:33:05 -0400 Subject: [PATCH 18/32] Simply enable STDIN gitstatus.py --- gitstatus.py | 2 +- zshrc.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gitstatus.py b/gitstatus.py index 0499a726..5ab3e0d2 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -130,7 +130,7 @@ def main(): Will read stdin and parse it. """ if not sys.stdin.isatty(): - lines = sys.stdin.readlines() + lines = [line.rstrip() for line in sys.stdin.readlines()] err = u'\n'.join(lines) else: proc = sub.Popen(['git', 'status', '--branch', '--porcelain'], diff --git a/zshrc.sh b/zshrc.sh index 6b7e830e..224bde5e 100644 --- a/zshrc.sh +++ b/zshrc.sh @@ -24,7 +24,7 @@ update_current_git_vars() { unset __CURRENT_GIT_STATUS if [ "$GIT_PROMPT_EXECUTABLE" = "python" ]; then - __GIT_CMD=$(ZSH_THEME_GIT_PROMPT_HASH_PREFIX=$ZSH_THEME_GIT_PROMPT_HASH_PREFIX python "$__GIT_PROMPT_DIR/gitstatus.py" 2>/dev/null) + __GIT_CMD=$(git status --porcelain --branch &> /dev/null | ZSH_THEME_GIT_PROMPT_HASH_PREFIX=$ZSH_THEME_GIT_PROMPT_HASH_PREFIX python "$__GIT_PROMPT_DIR/gitstatus.py") else __GIT_CMD=$(git status --porcelain --branch &> /dev/null | $__GIT_PROMPT_DIR/src/.bin/gitstatus) fi From 6e401d9fe03d1e0b22c79f70537a68db1e7e38c3 Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Tue, 29 May 2018 05:26:43 -0400 Subject: [PATCH 19/32] FIX: stderr to gitstatus stdin --- zshrc.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zshrc.sh b/zshrc.sh index 224bde5e..1d6018c6 100644 --- a/zshrc.sh +++ b/zshrc.sh @@ -24,7 +24,7 @@ update_current_git_vars() { unset __CURRENT_GIT_STATUS if [ "$GIT_PROMPT_EXECUTABLE" = "python" ]; then - __GIT_CMD=$(git status --porcelain --branch &> /dev/null | ZSH_THEME_GIT_PROMPT_HASH_PREFIX=$ZSH_THEME_GIT_PROMPT_HASH_PREFIX python "$__GIT_PROMPT_DIR/gitstatus.py") + __GIT_CMD=$(git status --porcelain --branch &> /dev/null 2>&1 | ZSH_THEME_GIT_PROMPT_HASH_PREFIX=$ZSH_THEME_GIT_PROMPT_HASH_PREFIX python "$__GIT_PROMPT_DIR/gitstatus.py") else __GIT_CMD=$(git status --porcelain --branch &> /dev/null | $__GIT_PROMPT_DIR/src/.bin/gitstatus) fi From bd26fbb3494eafb6a39b233a095ef0174a1ef16b Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Wed, 30 May 2018 05:39:17 -0400 Subject: [PATCH 20/32] Partial fix. --- gitstatus.py | 30 ++++++++++++++++++-- test_gitstatus.py | 70 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/gitstatus.py b/gitstatus.py index 5ab3e0d2..807e1722 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -15,6 +15,26 @@ SYM_PREHASH = os.environ.get('ZSH_THEME_GIT_PROMPT_HASH_PREFIX', ':') +# TODO: Naiev fix, consider work trees too. +def find_git_root(): + """ + Find the nearest enclosing git root. + Condition: Will be called from within a git respository. + + Returns: The git project root. + + Raises: IOError - Could not find the directory. + """ + 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.") + + def parse_stats(lines): """ Computes and returns the following _numbers_ describing the current state. @@ -87,7 +107,7 @@ def parse_branch(branch): upstream = SYM_NOUPSTREAM local = 1 if 'no branch' in branch: - with open('.git/HEAD') as fin: + with open(os.path.join(GIT_D, 'HEAD')) as fin: branch = SYM_PREHASH + fin.read().strip()[:7] local = 0 elif '...' in branch: @@ -110,7 +130,7 @@ def current_git_status(lines): stats = parse_stats(lines[1:]) try: - with open('.git/logs/refs/stash') as fin: + with open(os.path.join(GIT_D, 'logs', 'refs', 'stash')) as fin: stashed = len(fin.readlines()) except IOError: stashed = 0 @@ -146,5 +166,11 @@ def main(): sys.stdout.flush() +try: + GIT_D = find_git_root() +except IOError: + pass + + if __name__ == "__main__": main() diff --git a/test_gitstatus.py b/test_gitstatus.py index 37caf654..b8227466 100644 --- a/test_gitstatus.py +++ b/test_gitstatus.py @@ -513,6 +513,55 @@ def git_repo_remote_diverged(): 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: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + # os.makedirs(folder) + subs = os.path.join(folder, 'd_one', 'd_two', 'd_three') + print(subs) + 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: + subprocess.check_call(shlex.split(cmd), + stdout=devnull, stderr=subprocess.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + os.chdir(cwd) + + def test_branch_fatal(): """ Simple string to suppress doc warning. """ cwd = os.getcwd() @@ -597,3 +646,24 @@ def test_main_stdin(git_repo_parse_stats): finput.seek(0) out = subprocess.check_output(['python', GIT_STATUS], stdin=finput) assert out.decode('utf-8', errors='ignore') == 'master 0 0 3 0 1 2 1 0' + + +def test_find_git_root(git_repo_find_git_root): + 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(): + try: + temp_d = tempfile.mkdtemp() + cwd = os.getcwd() + os.chdir(temp_d) + + with pytest.raises(IOError): + gitstatus.find_git_root() + finally: + os.chdir(cwd) + shutil.rmtree(temp_d) From 87774a84cc0ec404978bd725708ac011e90ccfaf Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Wed, 30 May 2018 13:51:02 -0400 Subject: [PATCH 21/32] FIX: #13 Find root of active git project - Script aware now if in worktree or not, no visual difference in prompt at this point. --- gitstatus.py | 44 +++++++++-- test_gitstatus.py | 197 ++++++++++++++++++++++++---------------------- 2 files changed, 143 insertions(+), 98 deletions(-) diff --git a/gitstatus.py b/gitstatus.py index 807e1722..2c230ae2 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -13,17 +13,18 @@ SYM_NOUPSTREAM = '..' # This symbol appears before hashes when detached SYM_PREHASH = os.environ.get('ZSH_THEME_GIT_PROMPT_HASH_PREFIX', ':') +HEAD_FILE = None +STASH_FILE = None -# TODO: Naiev fix, consider work trees too. def find_git_root(): """ Find the nearest enclosing git root. - Condition: Will be called from within a git respository. Returns: The git project root. - Raises: IOError - Could not find the directory. + Raises: + IOError: There is no `.git` folder in the current folder hierarchy. """ working_d = os.getcwd() while working_d != '/': @@ -35,6 +36,37 @@ def find_git_root(): 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(): + """ + Determine the location of the head file and stash file for the current repository. + 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) + + Raises: + IOError: CWD is not in a git repository. + """ + git_root = find_git_root() + if os.path.isdir(git_root): + head_file = os.path.join(git_root, 'HEAD') + 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) + + head_file = os.path.join(tree_d, 'HEAD') + + stash_file = os.path.join(git_root, 'logs', 'refs', 'stash') + + return head_file, stash_file + + def parse_stats(lines): """ Computes and returns the following _numbers_ describing the current state. @@ -107,7 +139,7 @@ def parse_branch(branch): upstream = SYM_NOUPSTREAM local = 1 if 'no branch' in branch: - with open(os.path.join(GIT_D, 'HEAD')) as fin: + with open(HEAD_FILE) as fin: branch = SYM_PREHASH + fin.read().strip()[:7] local = 0 elif '...' in branch: @@ -130,7 +162,7 @@ def current_git_status(lines): stats = parse_stats(lines[1:]) try: - with open(os.path.join(GIT_D, 'logs', 'refs', 'stash')) as fin: + with open(STASH_FILE) as fin: stashed = len(fin.readlines()) except IOError: stashed = 0 @@ -167,7 +199,7 @@ def main(): try: - GIT_D = find_git_root() + HEAD_FILE, STASH_FILE = git_paths() except IOError: pass diff --git a/test_gitstatus.py b/test_gitstatus.py index b8227466..72fdde94 100644 --- a/test_gitstatus.py +++ b/test_gitstatus.py @@ -62,15 +62,6 @@ def git_repo_parse_stats(): "git branch --set-upstream-to=up/master", ] try: - try: - shutil.rmtree(folder) - except (OSError, IOError): - pass - try: - shutil.rmtree(folder_up) - except (OSError, IOError): - pass - os.makedirs(folder) os.chdir(folder) for cmd in cmds: @@ -129,15 +120,6 @@ def git_repo_parse_stats_only_conflicts(): "git merge up/master", ] try: - try: - shutil.rmtree(folder) - except (OSError, IOError): - pass - try: - shutil.rmtree(folder_up) - except (OSError, IOError): - pass - os.makedirs(folder) os.chdir(folder) for cmd in cmds: @@ -190,11 +172,6 @@ def git_repo_branch_on_hash(): "git checkout HEAD~1", ] try: - try: - shutil.rmtree(folder) - except (OSError, IOError): - pass - os.makedirs(folder) os.chdir(folder) for cmd in cmds: @@ -239,11 +216,6 @@ def git_repo_branch_on_master(): "git commit -m 'second commit'", ] try: - try: - shutil.rmtree(folder) - except (OSError, IOError): - pass - os.makedirs(folder) os.chdir(folder) for cmd in cmds: @@ -285,11 +257,6 @@ def git_repo_branch_local_only(): "git commit -m 'first commit'", ] try: - try: - shutil.rmtree(folder) - except (OSError, IOError): - pass - os.makedirs(folder) os.chdir(folder) for cmd in cmds: @@ -342,15 +309,6 @@ def git_repo_remote_ahead(): "git branch --set-upstream-to=up/master", ] try: - try: - shutil.rmtree(folder) - except (OSError, IOError): - pass - try: - shutil.rmtree(folder_up) - except (OSError, IOError): - pass - os.makedirs(folder) os.chdir(folder) for cmd in cmds: @@ -408,15 +366,6 @@ def git_repo_remote_behind(): "git reset --hard HEAD~1", ] try: - try: - shutil.rmtree(folder) - except (OSError, IOError): - pass - try: - shutil.rmtree(folder_up) - except (OSError, IOError): - pass - os.makedirs(folder) os.chdir(folder) for cmd in cmds: @@ -477,6 +426,22 @@ def git_repo_remote_diverged(): "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: + subprocess.check_call(shlex.split(cmd), + stdout=devnull, stderr=subprocess.STDOUT) + + yield + + finally: try: shutil.rmtree(folder) except (OSError, IOError): @@ -485,7 +450,29 @@ def git_repo_remote_diverged(): shutil.rmtree(folder_up) except (OSError, IOError): pass - os.makedirs(folder) + 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: @@ -506,22 +493,20 @@ def git_repo_remote_diverged(): 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_find_git_root(): +def git_repo_with_worktree(): """ Create a fake git repo with the following properties: - - 1 commit - - nested folders called, first/second/third + - 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'", @@ -529,16 +514,27 @@ def git_repo_find_git_root(): "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: try: shutil.rmtree(folder) except (OSError, IOError): pass - # os.makedirs(folder) - subs = os.path.join(folder, 'd_one', 'd_two', 'd_three') - print(subs) - os.makedirs(subs) + try: + shutil.rmtree(folder_tree) + except (OSError, IOError): + pass + os.makedirs(folder) os.chdir(folder) for cmd in cmds: @@ -552,6 +548,7 @@ def git_repo_find_git_root(): subprocess.check_call(shlex.split(cmd), stdout=devnull, stderr=subprocess.STDOUT) + os.chdir(folder_tree) yield finally: @@ -559,20 +556,21 @@ def git_repo_find_git_root(): shutil.rmtree(folder) except (OSError, IOError): pass + try: + shutil.rmtree(folder_tree) + except (OSError, IOError): + pass os.chdir(cwd) -def test_branch_fatal(): - """ Simple string to suppress doc warning. """ +@pytest.yield_fixture(scope="function") +def empty_working_directory(): cwd = os.getcwd() try: folder = tempfile.mkdtemp() os.chdir(folder) - try: - gitstatus.main() - assert False - except SystemExit: - pass + + yield finally: os.chdir(cwd) try: @@ -581,65 +579,74 @@ def test_branch_fatal(): pass +def test_branch_fatal(empty_working_directory): + """ A unit test for gitstatus. """ + try: + gitstatus.main() + assert False + except SystemExit: + pass + + def test_branch_master(git_repo_branch_on_master): - """ Simple string to suppress doc warning. """ + """ A unit test for gitstatus. """ assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1' def test_branch_hash(git_repo_branch_on_hash): - """ Simple string to suppress doc warning. """ + """ A unit test for gitstatus. """ actual_hash = subprocess.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'.format(actual_hash) def test_branch_local(git_repo_branch_local_only): - """ Simple string to suppress doc warning. """ + """ A unit test for gitstatus. """ assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1' def test_parse_stats_no_conflicts(git_repo_parse_stats): - """ Simple string to suppress doc warning. """ + """ A unit test for gitstatus. """ assert run_gitstatus() == 'master 0 0 3 0 1 2 1 0' def test_parse_stats_only_conflicts(git_repo_parse_stats_only_conflicts): - """ Simple string to suppress doc warning. """ + """ A unit test for gitstatus. """ assert run_gitstatus() == 'master 1 1 0 1 0 0 0 0' def test_remote_ahead(git_repo_remote_ahead): - """ Simple string to suppress doc warning. """ + """ A unit test for gitstatus. """ assert run_gitstatus() == 'master 0 1 0 0 0 0 0 0' def test_remote_behind(git_repo_remote_behind): - """ Simple string to suppress doc warning. """ + """ A unit test for gitstatus. """ assert run_gitstatus() == 'master 1 0 0 0 0 0 0 0' def test_remote_diverged(git_repo_remote_diverged): - """ Simple string to suppress doc warning. """ + """ A unit test for gitstatus. """ assert run_gitstatus() == 'master 1 1 0 0 0 0 0 0' def test_parse_ahead_behind_only_ahead(): - """ Simple string to suppress doc warning. """ + """ A unit test for gitstatus. """ assert gitstatus.parse_ahead_behind("## master...up/master [ahead 1]") == (0, 1) def test_parse_ahead_behind_only_behind(): - """ Simple string to suppress doc warning. """ + """ A unit test for gitstatus. """ assert gitstatus.parse_ahead_behind("## master...up/master [behind 1]") == (1, 0) def test_parse_ahead_behind_both(): - """ Simple string to suppress doc warning. """ + """ A unit test for gitstatus. """ assert gitstatus.parse_ahead_behind("## master...up/master [ahead 1, behind 1]") == (1, 1) def test_main_stdin(git_repo_parse_stats): - """ Simple string to suppress doc warning. """ + """ A unit test for gitstatus. """ out = subprocess.check_output(['git', 'status', '--branch', '--porcelain']) with tempfile.TemporaryFile() as finput: finput.write(out) @@ -649,6 +656,7 @@ def test_main_stdin(git_repo_parse_stats): 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) @@ -656,14 +664,19 @@ def test_find_git_root(git_repo_find_git_root): assert gitstatus.find_git_root() == expect -def test_find_git_root_fail(): - try: - temp_d = tempfile.mkdtemp() - cwd = os.getcwd() - os.chdir(temp_d) +def test_find_git_root_fail(empty_working_directory): + """ A unit test for gitstatus. """ + with pytest.raises(IOError): + gitstatus.find_git_root() - with pytest.raises(IOError): - gitstatus.find_git_root() - finally: - os.chdir(cwd) - shutil.rmtree(temp_d) + +def test_git_paths_in_working_tree(git_repo_with_worktree): + """ A unit test for gitstatus. """ + actual_hash = subprocess.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".format(gitstatus.SYM_PREHASH, actual_hash) + + +def test_main_not_in_repo(empty_working_directory): + """ A unit test for gitstatus. """ + assert run_gitstatus() == '' From 2b1069b69d77dabcf7891e958dc564df82d57b78 Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Tue, 29 May 2018 08:21:36 -0400 Subject: [PATCH 22/32] FIX: #4 Allow opt-in to show upstream - Typo in zshrc.sh --- README.md | 5 +++++ gitstatus.py | 15 ++++++--------- test_gitstatus.py | 21 +++++++++++---------- zshrc.sh | 21 ++++++++++++++++----- 4 files changed, 38 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 9b02c787..7ee31375 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ There is now a Haskell implementation as well, which can be four to six times fa - 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. + - 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). - To modify the symbols/colors of the theme, simply redefine the variables at bottom of the @@ -108,6 +110,9 @@ These are the defaults: 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!** diff --git a/gitstatus.py b/gitstatus.py index 2c230ae2..68b1cfd1 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -122,7 +122,6 @@ def parse_branch(branch): 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. - Capture status for later processing. Args: branch: The branch line of porcelain @@ -157,7 +156,7 @@ def current_git_status(lines): Returns: The formatted message representing the git repository. """ # TODO: Use upstream and update tests - branch, _, local = parse_branch(lines[0]) + branch, upstream, local = parse_branch(lines[0]) remote = parse_ahead_behind(lines[0]) stats = parse_stats(lines[1:]) @@ -167,7 +166,7 @@ def current_git_status(lines): except IOError: stashed = 0 - values = [str(x) for x in (branch,) + remote + stats + (stashed, local)] + values = [str(x) for x in (branch,) + remote + stats + (stashed, local, upstream)] return ' '.join(values) @@ -198,11 +197,9 @@ def main(): sys.stdout.flush() -try: - HEAD_FILE, STASH_FILE = git_paths() -except IOError: - pass - - if __name__ == "__main__": + try: + HEAD_FILE, STASH_FILE = git_paths() + except IOError: + pass main() diff --git a/test_gitstatus.py b/test_gitstatus.py index 72fdde94..e89fdef8 100644 --- a/test_gitstatus.py +++ b/test_gitstatus.py @@ -590,44 +590,44 @@ def test_branch_fatal(empty_working_directory): def test_branch_master(git_repo_branch_on_master): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1' + assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1 ' + gitstatus.SYM_NOUPSTREAM def test_branch_hash(git_repo_branch_on_hash): """ A unit test for gitstatus. """ actual_hash = subprocess.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'.format(actual_hash) + assert run_gitstatus() == ':{} 0 0 0 0 0 0 0 0 {}'.format(actual_hash, gitstatus.SYM_NOUPSTREAM) def test_branch_local(git_repo_branch_local_only): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1' + assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1 ' + gitstatus.SYM_NOUPSTREAM def test_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' + assert run_gitstatus() == 'master 0 0 3 0 1 2 1 0 up/master' def test_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' + assert run_gitstatus() == 'master 1 1 0 1 0 0 0 0 up/master' def test_remote_ahead(git_repo_remote_ahead): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 0 1 0 0 0 0 0 0' + assert run_gitstatus() == 'master 0 1 0 0 0 0 0 0 up/master' def test_remote_behind(git_repo_remote_behind): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 1 0 0 0 0 0 0 0' + assert run_gitstatus() == 'master 1 0 0 0 0 0 0 0 up/master' def test_remote_diverged(git_repo_remote_diverged): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 1 1 0 0 0 0 0 0' + assert run_gitstatus() == 'master 1 1 0 0 0 0 0 0 up/master' def test_parse_ahead_behind_only_ahead(): @@ -652,7 +652,7 @@ def test_main_stdin(git_repo_parse_stats): finput.write(out) finput.seek(0) out = subprocess.check_output(['python', GIT_STATUS], stdin=finput) - assert out.decode('utf-8', errors='ignore') == 'master 0 0 3 0 1 2 1 0' + assert out.decode('utf-8', errors='ignore') == 'master 0 0 3 0 1 2 1 0 up/master' def test_find_git_root(git_repo_find_git_root): @@ -674,7 +674,8 @@ def test_git_paths_in_working_tree(git_repo_with_worktree): """ A unit test for gitstatus. """ actual_hash = subprocess.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".format(gitstatus.SYM_PREHASH, actual_hash) + assert run_gitstatus() == "{}{} 0 0 0 0 0 0 0 0 {}".format(gitstatus.SYM_PREHASH, actual_hash, + gitstatus.SYM_NOUPSTREAM) def test_main_not_in_repo(empty_working_directory): diff --git a/zshrc.sh b/zshrc.sh index 1d6018c6..15e8107b 100644 --- a/zshrc.sh +++ b/zshrc.sh @@ -39,20 +39,28 @@ update_current_git_vars() { GIT_CHANGED=$__CURRENT_GIT_STATUS[6] GIT_UNTRACKED=$__CURRENT_GIT_STATUS[7] GIT_STASHED=$__CURRENT_GIT_STATUS[8] - GIT_LOCAL_ONLW=$__CURRENT_GIT_STATUS[9] + GIT_LOCAL_ONLY=$__CURRENT_GIT_STATUS[9] + GIT_UPSTREAM=$__CURRENT_GIT_STATUS[10] } git_super_status() { precmd_update_git_vars if [ -n "$__CURRENT_GIT_STATUS" ]; then - if [ "$GIT_LOCAL_ONLW" -ne "0" ]; then - GIT_BRANCH="$GIT_BRANCH%{${reset_color}%}$ZSH_THEME_GIT_PROMPT_LOCAL" - fi - local STATUS="$ZSH_THEME_GIT_PROMPT_PREFIX$ZSH_THEME_GIT_PROMPT_BRANCH$GIT_BRANCH%{${reset_color}%}" local clean=1 + if [ "$GIT_LOCAL_ONLY" -ne "0" ]; then + STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_LOCAL%{${reset_color}%}" + fi + if [ "$GIT_PROMPT_SHOW_UPSTREAM" -eq "1" ] && [ -n "$GIT_UPSTREAM" ] && [ "$GIT_UPSTREAM" != ".." ]; then + local parts=( "${(s:/:)GIT_UPSTREAM}" ) + if [ "$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 @@ -128,5 +136,8 @@ 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}%}}" # vim: set filetype=zsh: From f2ca95ee4a3da26aa5c496a76e91b92dcec15600 Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Thu, 31 May 2018 09:24:55 -0400 Subject: [PATCH 23/32] Allow setting python interpreter. --- README.md | 2 ++ zshrc.sh | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7ee31375..be371c11 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ There is now a Haskell implementation as well, which can be four to six times fa - Define the variable `ZSH_GIT_PROMPT_SHOW_UPSTREAM=1` in order to see the remote branch you are tracking. +- 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). - To modify the symbols/colors of the theme, simply redefine the variables at bottom of the diff --git a/zshrc.sh b/zshrc.sh index 15e8107b..52668211 100644 --- a/zshrc.sh +++ b/zshrc.sh @@ -24,7 +24,8 @@ update_current_git_vars() { unset __CURRENT_GIT_STATUS if [ "$GIT_PROMPT_EXECUTABLE" = "python" ]; then - __GIT_CMD=$(git status --porcelain --branch &> /dev/null 2>&1 | ZSH_THEME_GIT_PROMPT_HASH_PREFIX=$ZSH_THEME_GIT_PROMPT_HASH_PREFIX python "$__GIT_PROMPT_DIR/gitstatus.py") + 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 From c6f3567f0e102469072e4bd3fdecc601b8fb7f48 Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Fri, 1 Jun 2018 22:04:05 -0400 Subject: [PATCH 24/32] Allow abbreviated show upstream. --- README.md | 8 +++++++- zshrc.sh | 6 +++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index be371c11..7c77f310 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The symbols are as follows: |✖n |there are `n` unmerged files |✚n |there are `n` changed but *unstaged* files |…n |there are `n` untracked files -|⚑n |there are `n` stashed files +|⚑n |there are `n` stashes on the repo ### Branch Tracking Symbols @@ -89,6 +89,12 @@ There is now a Haskell implementation as well, which can be four to six times fa - 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. + +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). diff --git a/zshrc.sh b/zshrc.sh index 52668211..058e3bcb 100644 --- a/zshrc.sh +++ b/zshrc.sh @@ -54,10 +54,10 @@ git_super_status() { if [ "$GIT_LOCAL_ONLY" -ne "0" ]; then STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_LOCAL%{${reset_color}%}" fi - if [ "$GIT_PROMPT_SHOW_UPSTREAM" -eq "1" ] && [ -n "$GIT_UPSTREAM" ] && [ "$GIT_UPSTREAM" != ".." ]; then + if [ "$ZSH_GIT_PROMPT_SHOW_UPSTREAM" -gt "0" ] && [ -n "$GIT_UPSTREAM" ] && [ "$GIT_UPSTREAM" != ".." ]; then local parts=( "${(s:/:)GIT_UPSTREAM}" ) - if [ "$parts[2]" = "$GIT_BRANCH" ]; then - GIT_UPSTREAM="$parts[1]/" + 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 From 814dc534f3917cadf577a63142082754518210ca Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Sat, 2 Jun 2018 11:57:04 -0400 Subject: [PATCH 25/32] Fork note. --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 7c77f310..f77c664e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,16 @@ A `zsh` prompt that displays information about the current git repository. In pa (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: From e02d4b136970043220ef46618dad92844c23e200 Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Mon, 4 Jun 2018 09:40:19 -0400 Subject: [PATCH 26/32] FIX #16: Reset color in prefix - Ensure consistent colouring by using resets around output. --- zshrc.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zshrc.sh b/zshrc.sh index 058e3bcb..3e7adde0 100644 --- a/zshrc.sh +++ b/zshrc.sh @@ -95,10 +95,10 @@ git_super_status() { clean=0 fi if [ "$clean" -eq "1" ]; then - STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_CLEAN" + STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_CLEAN%{${reset_color}%}" fi - echo "$STATUS%{${reset_color}%}$ZSH_THEME_GIT_PROMPT_SUFFIX" + echo "%{${reset_color}%}$STATUS$ZSH_THEME_GIT_PROMPT_SUFFIX%{${reset_color}%}" fi } From 4e45c4f839b5f95c66a8a467b9094043d86febd0 Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Mon, 4 Jun 2018 09:05:36 -0400 Subject: [PATCH 27/32] FIX #17: handle repo lacking initial commit - Increase function purity by reducing global usage. --- gitstatus.py | 46 ++++++++++++++++++++++----------------------- test_gitstatus.py | 48 ++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 65 insertions(+), 29 deletions(-) diff --git a/gitstatus.py b/gitstatus.py index 68b1cfd1..e119011b 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -13,8 +13,6 @@ SYM_NOUPSTREAM = '..' # This symbol appears before hashes when detached SYM_PREHASH = os.environ.get('ZSH_THEME_GIT_PROMPT_HASH_PREFIX', ':') -HEAD_FILE = None -STASH_FILE = None def find_git_root(): @@ -38,18 +36,14 @@ def find_git_root(): # Example contents of worktree `.git` file, worktree is w1 # gitdir: /tmp/zsh-git-prompt/.git/worktrees/w1 -def git_paths(): +def git_paths(git_root): """ Determine the location of the head file and stash file for the current repository. 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) - - Raises: - IOError: CWD is not in a git repository. """ - git_root = find_git_root() if os.path.isdir(git_root): head_file = os.path.join(git_root, 'HEAD') else: # worktree @@ -117,7 +111,7 @@ def parse_ahead_behind(branch): return behind, ahead -def parse_branch(branch): +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. @@ -137,13 +131,15 @@ def parse_branch(branch): upstream = SYM_NOUPSTREAM local = 1 - if 'no branch' in branch: - with open(HEAD_FILE) as fin: - branch = SYM_PREHASH + fin.read().strip()[:7] - local = 0 - elif '...' in branch: + 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 @@ -154,14 +150,17 @@ def current_git_status(lines): 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. """ - # TODO: Use upstream and update tests - branch, upstream, local = parse_branch(lines[0]) + head_file, stash_file = 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:]) try: - with open(STASH_FILE) as fin: + with open(stash_file) as fin: stashed = len(fin.readlines()) except IOError: stashed = 0 @@ -190,16 +189,15 @@ def main(): err = err.decode('utf-8', errors='ignore').strip() lines = out.decode('utf-8', errors='ignore').splitlines() - if 'fatal: not a git repository' in err.lower(): - sys.exit(0) + if err.lower().startswith('fatal: not a git repository'): + return - sys.stdout.write(current_git_status(lines)) - sys.stdout.flush() + try: + sys.stdout.write(current_git_status(lines)) + sys.stdout.flush() + except IOError: # pragma: no cover + pass if __name__ == "__main__": - try: - HEAD_FILE, STASH_FILE = git_paths() - except IOError: - pass main() diff --git a/test_gitstatus.py b/test_gitstatus.py index e89fdef8..1fb6001c 100644 --- a/test_gitstatus.py +++ b/test_gitstatus.py @@ -579,13 +579,51 @@ def empty_working_directory(): 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: + subprocess.check_call(shlex.split(cmd), + stdout=devnull, stderr=subprocess.STDOUT) + + yield + + finally: + try: + shutil.rmtree(folder) + except (OSError, IOError): + pass + os.chdir(cwd) + + def test_branch_fatal(empty_working_directory): """ A unit test for gitstatus. """ - try: - gitstatus.main() - assert False - except SystemExit: - pass + assert run_gitstatus() == '' + + +def test_branch_initial_commit(git_repo_initial_commit): + """ A unit test for gitstatus. """ + assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1 ' + gitstatus.SYM_NOUPSTREAM def test_branch_master(git_repo_branch_on_master): From bec2226153a9bdc57f65039b072cae807439ac7e Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Mon, 4 Jun 2018 21:10:14 -0400 Subject: [PATCH 28/32] FIX #14: Rebase in progress indicator --- gitstatus.py | 21 ++++++- test_gitstatus.py | 157 +++++++++++++++++++++++++++++++--------------- zshrc.sh | 6 ++ 3 files changed, 133 insertions(+), 51 deletions(-) diff --git a/gitstatus.py b/gitstatus.py index e119011b..cdca395a 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -144,6 +144,24 @@ def parse_branch(branch, head_file): return branch, upstream, local +def parse_rebase(rebase_d): + """ + Determine the rebase status of this repostitory and return it. + + Returns: + - "0": No active rebase. + - "1/4": Rebase in progress, commit 1 of 4. + """ + try: + with open(os.path.join(rebase_d, 'next')) as next_file,\ + open(os.path.join(rebase_d, '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 @@ -158,6 +176,7 @@ def current_git_status(lines): branch, upstream, local = parse_branch(lines[0], head_file) remote = parse_ahead_behind(lines[0]) stats = parse_stats(lines[1:]) + rebase = parse_rebase(os.path.join(os.path.dirname(head_file), 'rebase-apply')) try: with open(stash_file) as fin: @@ -165,7 +184,7 @@ def current_git_status(lines): except IOError: stashed = 0 - values = [str(x) for x in (branch,) + remote + stats + (stashed, local, upstream)] + values = [str(x) for x in (branch,) + remote + stats + (stashed, local, upstream, rebase)] return ' '.join(values) diff --git a/test_gitstatus.py b/test_gitstatus.py index 1fb6001c..e6071b2c 100644 --- a/test_gitstatus.py +++ b/test_gitstatus.py @@ -9,7 +9,7 @@ import re import shlex import shutil -import subprocess +import subprocess as sub import tempfile import pytest @@ -26,7 +26,7 @@ def run_gitstatus(): Returns: The output of gitstatus.py in the CWD. """ - return subprocess.check_output(['python', GIT_STATUS]).decode('utf-8', errors='ignore') + return sub.check_output(['python', GIT_STATUS]).decode('utf-8', errors='ignore') @pytest.yield_fixture(scope="function") @@ -72,8 +72,8 @@ def git_repo_parse_stats(): fout.write(text + '\n') else: with open(os.devnull, 'w') as devnull: - subprocess.check_call(shlex.split(cmd), - stdout=devnull, stderr=subprocess.STDOUT) + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) yield @@ -131,9 +131,9 @@ def git_repo_parse_stats_only_conflicts(): else: try: with open(os.devnull, 'w') as devnull: - subprocess.check_call(shlex.split(cmd), - stdout=devnull, stderr=subprocess.STDOUT) - except subprocess.CalledProcessError: + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) + except sub.CalledProcessError: pass yield @@ -182,8 +182,8 @@ def git_repo_branch_on_hash(): fout.write(text + '\n') else: with open(os.devnull, 'w') as devnull: - subprocess.check_call(shlex.split(cmd), - stdout=devnull, stderr=subprocess.STDOUT) + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) yield @@ -226,8 +226,8 @@ def git_repo_branch_on_master(): fout.write(text + '\n') else: with open(os.devnull, 'w') as devnull: - subprocess.check_call(shlex.split(cmd), - stdout=devnull, stderr=subprocess.STDOUT) + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) yield @@ -267,8 +267,8 @@ def git_repo_branch_local_only(): fout.write(text + '\n') else: with open(os.devnull, 'w') as devnull: - subprocess.check_call(shlex.split(cmd), - stdout=devnull, stderr=subprocess.STDOUT) + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) yield @@ -319,8 +319,8 @@ def git_repo_remote_ahead(): fout.write(text + '\n') else: with open(os.devnull, 'w') as devnull: - subprocess.check_call(shlex.split(cmd), - stdout=devnull, stderr=subprocess.STDOUT) + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) yield @@ -376,8 +376,8 @@ def git_repo_remote_behind(): fout.write(text + '\n') else: with open(os.devnull, 'w') as devnull: - subprocess.check_call(shlex.split(cmd), - stdout=devnull, stderr=subprocess.STDOUT) + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) yield @@ -436,8 +436,8 @@ def git_repo_remote_diverged(): fout.write(text + '\n') else: with open(os.devnull, 'w') as devnull: - subprocess.check_call(shlex.split(cmd), - stdout=devnull, stderr=subprocess.STDOUT) + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) yield @@ -483,8 +483,8 @@ def git_repo_find_git_root(): fout.write(text + '\n') else: with open(os.devnull, 'w') as devnull: - subprocess.check_call(shlex.split(cmd), - stdout=devnull, stderr=subprocess.STDOUT) + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) yield @@ -526,15 +526,6 @@ def git_repo_with_worktree(): "git worktree add --detach %s tree" % (folder_tree), ] try: - try: - shutil.rmtree(folder) - except (OSError, IOError): - pass - try: - shutil.rmtree(folder_tree) - except (OSError, IOError): - pass - os.makedirs(folder) os.chdir(folder) for cmd in cmds: @@ -545,8 +536,8 @@ def git_repo_with_worktree(): fout.write(text + '\n') else: with open(os.devnull, 'w') as devnull: - subprocess.check_call(shlex.split(cmd), - stdout=devnull, stderr=subprocess.STDOUT) + sub.check_call(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) os.chdir(folder_tree) yield @@ -603,8 +594,66 @@ def git_repo_initial_commit(): fout.write(text + '\n') else: with open(os.devnull, 'w') as devnull: - subprocess.check_call(shlex.split(cmd), - stdout=devnull, stderr=subprocess.STDOUT) + 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_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 @@ -623,49 +672,50 @@ def test_branch_fatal(empty_working_directory): def test_branch_initial_commit(git_repo_initial_commit): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1 ' + gitstatus.SYM_NOUPSTREAM + assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1 {} 0'.format(gitstatus.SYM_NOUPSTREAM) def test_branch_master(git_repo_branch_on_master): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1 ' + gitstatus.SYM_NOUPSTREAM + assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1 {} 0'.format(gitstatus.SYM_NOUPSTREAM) def test_branch_hash(git_repo_branch_on_hash): """ A unit test for gitstatus. """ - actual_hash = subprocess.check_output(shlex.split('git rev-parse --short HEAD')) + 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 {}'.format(actual_hash, gitstatus.SYM_NOUPSTREAM) + assert run_gitstatus() == ':{} 0 0 0 0 0 0 0 0 {} 0'.format(actual_hash, + gitstatus.SYM_NOUPSTREAM) def test_branch_local(git_repo_branch_local_only): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1 ' + gitstatus.SYM_NOUPSTREAM + assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1 {} 0'.format(gitstatus.SYM_NOUPSTREAM) def test_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' + assert run_gitstatus() == 'master 0 0 3 0 1 2 1 0 up/master 0' def test_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' + assert run_gitstatus() == 'master 1 1 0 1 0 0 0 0 up/master 0' def test_remote_ahead(git_repo_remote_ahead): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 0 1 0 0 0 0 0 0 up/master' + assert run_gitstatus() == 'master 0 1 0 0 0 0 0 0 up/master 0' def test_remote_behind(git_repo_remote_behind): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 1 0 0 0 0 0 0 0 up/master' + assert run_gitstatus() == 'master 1 0 0 0 0 0 0 0 up/master 0' def test_remote_diverged(git_repo_remote_diverged): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 1 1 0 0 0 0 0 0 up/master' + assert run_gitstatus() == 'master 1 1 0 0 0 0 0 0 up/master 0' def test_parse_ahead_behind_only_ahead(): @@ -685,12 +735,12 @@ def test_parse_ahead_behind_both(): def test_main_stdin(git_repo_parse_stats): """ A unit test for gitstatus. """ - out = subprocess.check_output(['git', 'status', '--branch', '--porcelain']) + out = sub.check_output(['git', 'status', '--branch', '--porcelain']) with tempfile.TemporaryFile() as finput: finput.write(out) finput.seek(0) - out = subprocess.check_output(['python', GIT_STATUS], stdin=finput) - assert out.decode('utf-8', errors='ignore') == 'master 0 0 3 0 1 2 1 0 up/master' + out = sub.check_output(['python', GIT_STATUS], stdin=finput) + assert out.decode('utf-8', errors='ignore') == 'master 0 0 3 0 1 2 1 0 up/master 0' def test_find_git_root(git_repo_find_git_root): @@ -710,12 +760,19 @@ def test_find_git_root_fail(empty_working_directory): def test_git_paths_in_working_tree(git_repo_with_worktree): """ A unit test for gitstatus. """ - actual_hash = subprocess.check_output(shlex.split('git rev-parse --short HEAD')) + 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 {}".format(gitstatus.SYM_PREHASH, actual_hash, - gitstatus.SYM_NOUPSTREAM) + assert run_gitstatus() == "{}{} 0 0 0 0 0 0 0 0 {} 0".format(gitstatus.SYM_PREHASH, actual_hash, + gitstatus.SYM_NOUPSTREAM) def test_main_not_in_repo(empty_working_directory): """ A unit test for gitstatus. """ assert run_gitstatus() == '' + + +def test_rebase_in_progress(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 .. 1/2'.format(actual_hash) diff --git a/zshrc.sh b/zshrc.sh index 3e7adde0..95d52a09 100644 --- a/zshrc.sh +++ b/zshrc.sh @@ -42,6 +42,7 @@ update_current_git_vars() { GIT_STASHED=$__CURRENT_GIT_STATUS[8] GIT_LOCAL_ONLY=$__CURRENT_GIT_STATUS[9] GIT_UPSTREAM=$__CURRENT_GIT_STATUS[10] + GIT_REBASE=$__CURRENT_GIT_STATUS[11] } git_super_status() { @@ -51,6 +52,10 @@ git_super_status() { local STATUS="$ZSH_THEME_GIT_PROMPT_PREFIX$ZSH_THEME_GIT_PROMPT_BRANCH$GIT_BRANCH%{${reset_color}%}" local clean=1 + if [ "$GIT_REBASE" != "0" ]; then + STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_REBASE$GIT_REBASE%{${reset_color}%}" + fi + if [ "$GIT_LOCAL_ONLY" -ne "0" ]; then STATUS="$STATUS$ZSH_THEME_GIT_PROMPT_LOCAL%{${reset_color}%}" fi @@ -140,5 +145,6 @@ 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_REBASE="%{$fg_bold[magenta]%}|REBASE%{${reset_color}%} " # vim: set filetype=zsh: From fab5894813bdaad74c2bbb0611b31b13a18b0c03 Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Tue, 5 Jun 2018 10:15:13 -0400 Subject: [PATCH 29/32] FIX #11: When merging indicate in prompt - Overhaul of tests. Clearly separate functional and integration tests. - git_paths now resolves possible merge_file and rebase_dir, not guaranteed to exist. - Update README to show all possible branch states, including merging and rebase now added. --- README.md | 33 ++-- gitstatus.py | 168 ++++++++++------- test_gitstatus.py | 472 +++++++++++++++++++++++++++++----------------- zshrc.sh | 9 +- 4 files changed, 424 insertions(+), 258 deletions(-) diff --git a/README.md b/README.md index f77c664e..b9b24164 100644 --- a/README.md +++ b/README.md @@ -46,25 +46,30 @@ 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 -|…n |there are `n` untracked files -|⚑n |there are `n` stashes on the repo +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 Symbol +### Branch States + +- **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. It should be pretty clear, unless you name your branches like hashes :-) diff --git a/gitstatus.py b/gitstatus.py index cdca395a..7c7a4cd6 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -17,12 +17,12 @@ def find_git_root(): """ - Find the nearest enclosing git root. + Find the nearest enclosing git root (i.e. the path to .git). - Returns: The git project root. + Returns: The path to the .git project root Raises: - IOError: There is no `.git` folder in the current folder hierarchy. + IOError: There is no `.git` folder in the current folder hierarchy """ working_d = os.getcwd() while working_d != '/': @@ -38,14 +38,18 @@ def find_git_root(): # gitdir: /tmp/zsh-git-prompt/.git/worktrees/w1 def git_paths(git_root): """ - Determine the location of the head file and stash file for the current repository. + 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) + Returns: (head_file, stash_file, merge_file, rebase_dir) """ if os.path.isdir(git_root): - head_file = os.path.join(git_root, 'HEAD') + tree_d = git_root else: # worktree with open(git_root) as fin: tree_d = fin.read().split(": ")[1].strip() @@ -54,23 +58,77 @@ def git_paths(git_root): while os.path.basename(git_root) != '.git': git_root = os.path.dirname(git_root) - head_file = os.path.join(tree_d, 'HEAD') - 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 - return head_file, stash_file + +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 ', '')) + else: + behind = int(part.replace('behind ', '')) + + return behind, ahead def parse_stats(lines): """ - Computes and returns the following _numbers_ describing the current state. + 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) + Returns: (# staged, # conflicts, # changed, # untracked) """ staged, conflicts, changed, untracked = 0, 0, 0, 0 @@ -90,71 +148,38 @@ def parse_stats(lines): return staged, conflicts, changed, untracked -def parse_ahead_behind(branch): +def stash_count(stash_file): """ - Parse how far ahead and/or behind the branch and remote are. + Determine the number of stashes on the repository by looking at the stash log. Args: - branch: The branch line of porcelain + stash_file: The path to the stash log - Returns: - (# commits behind, # commits ahead) + Returns: The number of stashes """ - 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 ', '')) - else: - behind = int(part.replace('behind ', '')) + try: + with open(stash_file) as fin: + stashes = len(fin.readlines()) + except IOError: + stashes = 0 - return behind, ahead + return stashes -def parse_branch(branch, head_file): +def rebase_progress(rebase_dir): """ - 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. + Determine the rebase status of this repostitory and return it. Args: - branch: The branch line of porcelain - - 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_rebase(rebase_d): - """ - Determine the rebase status of this repostitory and return it. + rebase_dir: The path to the rebasing directory Returns: - - "0": No active rebase. - - "1/4": Rebase in progress, commit 1 of 4. + - "0": No active rebase + - "1/4": Rebase in progress, commit 1 of 4 """ try: - with open(os.path.join(rebase_d, 'next')) as next_file,\ - open(os.path.join(rebase_d, 'last')) as last_file: + 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' @@ -167,24 +192,21 @@ 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. + Returns: The formatted message representing the git repository Raises: - IOError: There is no `.git` folder in the current folder hierarchy. + IOError: There is no `.git` folder in the current folder hierarchy """ - head_file, stash_file = git_paths(find_git_root()) + 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:]) - rebase = parse_rebase(os.path.join(os.path.dirname(head_file), 'rebase-apply')) - - try: - with open(stash_file) as fin: - stashed = len(fin.readlines()) - except IOError: - stashed = 0 + 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 + (stashed, local, upstream, rebase)] + values = [str(x) for x in (branch,) + remote + stats + + (stashes, local, upstream, merge, rebase)] return ' '.join(values) diff --git a/test_gitstatus.py b/test_gitstatus.py index e6071b2c..97fcca3f 100644 --- a/test_gitstatus.py +++ b/test_gitstatus.py @@ -29,6 +29,163 @@ def run_gitstatus(): 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(): """ @@ -454,11 +611,12 @@ def git_repo_remote_diverged(): @pytest.yield_fixture(scope="function") -def git_repo_find_git_root(): +def git_repo_in_merge(): """ Create a fake git repo with the following properties: - - 1 commit - - nested folders called, first/second/third + - 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() @@ -469,119 +627,15 @@ def git_repo_find_git_root(): "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 branch dev", + "first:the second master line here", "git add first", - "git commit -m 'second commit'", - "git branch tree", - "git checkout tree", - "first:third line", + "git commit -m 'second master commit'", + "git checkout dev", + "first:Second line for dev", "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 empty_working_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'", + "git commit -m 'second dev commit'", + "git merge master", ] try: os.chdir(folder) @@ -594,8 +648,9 @@ def git_repo_initial_commit(): fout.write(text + '\n') else: with open(os.devnull, 'w') as devnull: - sub.check_call(shlex.split(cmd), - stdout=devnull, stderr=sub.STDOUT) + proc = sub.Popen(shlex.split(cmd), + stdout=devnull, stderr=sub.STDOUT) + proc.wait() yield @@ -665,114 +720,195 @@ def git_repo_in_rebase(): os.chdir(cwd) -def test_branch_fatal(empty_working_directory): +# ---------------- +# Functional Tests +# ---------------- +def test_find_git_root(git_repo_find_git_root): """ A unit test for gitstatus. """ - assert run_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_branch_initial_commit(git_repo_initial_commit): +def test_find_git_root_fail(empty_working_directory): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1 {} 0'.format(gitstatus.SYM_NOUPSTREAM) + with pytest.raises(IOError): + gitstatus.find_git_root() -def test_branch_master(git_repo_branch_on_master): +def test_git_paths_in_normal_repo(git_repo_initial_commit): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1 {} 0'.format(gitstatus.SYM_NOUPSTREAM) + 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_branch_hash(git_repo_branch_on_hash): +def test_git_paths_in_working_tree(git_repo_with_worktree): """ 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'.format(actual_hash, - gitstatus.SYM_NOUPSTREAM) + 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_branch_local(git_repo_branch_local_only): +def test_parse_stats(): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1 {} 0'.format(gitstatus.SYM_NOUPSTREAM) + 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_stats_no_conflicts(git_repo_parse_stats): +def test_parse_ahead_behind_only_ahead(): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 0 0 3 0 1 2 1 0 up/master 0' + assert gitstatus.parse_ahead_behind("## master...up/master [ahead 2]") == (0, 2) -def test_parse_stats_only_conflicts(git_repo_parse_stats_only_conflicts): +def test_parse_ahead_behind_only_behind(): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 1 1 0 1 0 0 0 0 up/master 0' + assert gitstatus.parse_ahead_behind("## master...up/master [behind 1]") == (1, 0) -def test_remote_ahead(git_repo_remote_ahead): +def test_parse_ahead_behind_both(): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 0 1 0 0 0 0 0 0 up/master 0' + assert gitstatus.parse_ahead_behind("## master...up/master [ahead 2, behind 1]") == (1, 2) -def test_remote_behind(git_repo_remote_behind): +def test_parse_branch_on_local_branch(): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 1 0 0 0 0 0 0 0 up/master 0' + branch_line = "## master" + assert gitstatus.parse_branch(branch_line, None) == ('master', '..', 1) -def test_remote_diverged(git_repo_remote_diverged): +def test_parse_branch_has_upstream(): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 1 1 0 0 0 0 0 0 up/master 0' + branch_line = "## master...up/master [ahead 2, behind 1]" + assert gitstatus.parse_branch(branch_line, None) == ('master', 'up/master', 0) -def test_parse_ahead_behind_only_ahead(): +def test_parse_branch_out_on_hash(git_repo_branch_on_hash): """ A unit test for gitstatus. """ - assert gitstatus.parse_ahead_behind("## master...up/master [ahead 1]") == (0, 1) + 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_parse_ahead_behind_only_behind(): +def test_stash_count_one_stash(git_repo_parse_stats): """ A unit test for gitstatus. """ - assert gitstatus.parse_ahead_behind("## master...up/master [behind 1]") == (1, 0) + stash_file = os.path.join(os.getcwd(), '.git', 'logs', 'refs', 'stash') + assert gitstatus.stash_count(stash_file) == 1 -def test_parse_ahead_behind_both(): +def test_stash_count_no_stash(git_repo_initial_commit): """ A unit test for gitstatus. """ - assert gitstatus.parse_ahead_behind("## master...up/master [ahead 1, behind 1]") == (1, 1) + 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_main_stdin(git_repo_parse_stats): +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' + + +# ----------------- +# Integration Tests +# ----------------- +def test_gitstatus_no_repo(empty_working_directory): """ A unit test for gitstatus. """ - out = sub.check_output(['git', 'status', '--branch', '--porcelain']) - with tempfile.TemporaryFile() as finput: - finput.write(out) - finput.seek(0) - out = sub.check_output(['python', GIT_STATUS], stdin=finput) - assert out.decode('utf-8', errors='ignore') == 'master 0 0 3 0 1 2 1 0 up/master 0' + assert run_gitstatus() == '' -def test_find_git_root(git_repo_find_git_root): +def test_gitstatus_initial_commit(git_repo_initial_commit): """ 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 + assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1 {} 0 0'.format(gitstatus.SYM_NOUPSTREAM) -def test_find_git_root_fail(empty_working_directory): +def test_gitstatus_local_branch(git_repo_branch_on_master): """ A unit test for gitstatus. """ - with pytest.raises(IOError): - gitstatus.find_git_root() + assert run_gitstatus() == 'master 0 0 0 0 0 0 0 1 {} 0 0'.format(gitstatus.SYM_NOUPSTREAM) -def test_git_paths_in_working_tree(git_repo_with_worktree): +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".format(gitstatus.SYM_PREHASH, actual_hash, - gitstatus.SYM_NOUPSTREAM) + assert run_gitstatus() == ':{} 0 0 0 0 0 0 0 0 {} 0 0'.format(actual_hash, + gitstatus.SYM_NOUPSTREAM) -def test_main_not_in_repo(empty_working_directory): +def test_gitstatus_parse_stats_no_conflicts(git_repo_parse_stats): """ A unit test for gitstatus. """ - assert run_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 0 1 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 1 0 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 1 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_rebase_in_progress(git_repo_in_rebase): +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 .. 1/2'.format(actual_hash) + assert run_gitstatus() == ':{} 0 0 0 1 0 0 0 0 .. 0 1/2'.format(actual_hash) diff --git a/zshrc.sh b/zshrc.sh index 95d52a09..78734b43 100644 --- a/zshrc.sh +++ b/zshrc.sh @@ -42,7 +42,8 @@ update_current_git_vars() { GIT_STASHED=$__CURRENT_GIT_STATUS[8] GIT_LOCAL_ONLY=$__CURRENT_GIT_STATUS[9] GIT_UPSTREAM=$__CURRENT_GIT_STATUS[10] - GIT_REBASE=$__CURRENT_GIT_STATUS[11] + GIT_MERGING=$__CURRENT_GIT_STATUS[11] + GIT_REBASE=$__CURRENT_GIT_STATUS[12] } git_super_status() { @@ -54,12 +55,13 @@ git_super_status() { if [ "$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}%}" - fi - if [ "$ZSH_GIT_PROMPT_SHOW_UPSTREAM" -gt "0" ] && [ -n "$GIT_UPSTREAM" ] && [ "$GIT_UPSTREAM" != ".." ]; then + 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]" @@ -145,6 +147,7 @@ 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: From 7bb9ea5a71bff53d914dce9b07d5526a7387e154 Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Tue, 5 Jun 2018 13:10:43 -0400 Subject: [PATCH 30/32] Guard needed while haskell lags behind. --- zshrc.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zshrc.sh b/zshrc.sh index 78734b43..06d63367 100644 --- a/zshrc.sh +++ b/zshrc.sh @@ -53,7 +53,7 @@ git_super_status() { local STATUS="$ZSH_THEME_GIT_PROMPT_PREFIX$ZSH_THEME_GIT_PROMPT_BRANCH$GIT_BRANCH%{${reset_color}%}" local clean=1 - if [ "$GIT_REBASE" != "0" ]; then + 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}%}" From f704934080c2d3dfc721825c937ab4796dd4a5eb Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Fri, 15 Jun 2018 22:01:30 -0400 Subject: [PATCH 31/32] FIX #21: Fix order of gitstatus.py & zshrc.sh - Clarify diverge test, different numbers. --- gitstatus.py | 2 +- test_gitstatus.py | 33 ++++++++++++++++++--------------- zshrc.sh | 4 ++-- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/gitstatus.py b/gitstatus.py index 7c7a4cd6..c23ec137 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -117,7 +117,7 @@ def parse_ahead_behind(branch): else: behind = int(part.replace('behind ', '')) - return behind, ahead + return ahead, behind def parse_stats(lines): diff --git a/test_gitstatus.py b/test_gitstatus.py index 97fcca3f..182ade4f 100644 --- a/test_gitstatus.py +++ b/test_gitstatus.py @@ -555,8 +555,8 @@ def git_repo_remote_diverged(): """ 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 + - upstream repo has 4 commits + - main repo has upstream set and is has diverged 2 behind, 1 ahead """ cwd = os.getcwd() folder = tempfile.mkdtemp() @@ -574,10 +574,13 @@ def git_repo_remote_diverged(): "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~1", + "git reset --hard HEAD~2", "first:different third line", "git add first", "git commit -m 'different third commit'", @@ -720,9 +723,9 @@ def git_repo_in_rebase(): os.chdir(cwd) -# ---------------- -# Functional Tests -# ---------------- +# ---------- +# Unit Tests +# ---------- def test_find_git_root(git_repo_find_git_root): """ A unit test for gitstatus. """ expect = os.path.join(os.getcwd(), '.git') @@ -785,17 +788,17 @@ def test_parse_stats(): def test_parse_ahead_behind_only_ahead(): """ A unit test for gitstatus. """ - assert gitstatus.parse_ahead_behind("## master...up/master [ahead 2]") == (0, 2) + 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]") == (1, 0) + 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]") == (1, 2) + assert gitstatus.parse_ahead_behind("## master...up/master [ahead 2, behind 1]") == (2, 1) def test_parse_branch_on_local_branch(): @@ -841,9 +844,9 @@ def test_rebase_progress_no_rebase(git_repo_initial_commit): assert gitstatus.rebase_progress(rebase_dir) == '0' -# ----------------- -# Integration Tests -# ----------------- +# ---------------- +# Functional Tests +# ---------------- def test_gitstatus_no_repo(empty_working_directory): """ A unit test for gitstatus. """ assert run_gitstatus() == '' @@ -879,17 +882,17 @@ def test_gitstatus_parse_stats_only_conflicts(git_repo_parse_stats_only_conflict def test_gitstatus_remote_ahead(git_repo_remote_ahead): """ A unit test for gitstatus. """ - assert run_gitstatus() == 'master 0 1 0 0 0 0 0 0 up/master 0 0' + 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 1 0 0 0 0 0 0 0 up/master 0 0' + 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 1 0 0 0 0 0 0 up/master 0 0' + assert run_gitstatus() == 'master 1 2 0 0 0 0 0 0 up/master 0 0' def test_gitstatus_stdin(git_repo_parse_stats): diff --git a/zshrc.sh b/zshrc.sh index 06d63367..4c1c9b3e 100644 --- a/zshrc.sh +++ b/zshrc.sh @@ -33,8 +33,8 @@ update_current_git_vars() { unset __GIT_CMD GIT_BRANCH=$__CURRENT_GIT_STATUS[1] - GIT_BEHIND=$__CURRENT_GIT_STATUS[2] - GIT_AHEAD=$__CURRENT_GIT_STATUS[3] + 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] From 11b83ba3b85d14c66cf2ab79faefab6d838da28e Mon Sep 17 00:00:00 2001 From: "Jeremy Pallats/starcraft.man" Date: Sat, 16 Jun 2018 12:27:38 -0400 Subject: [PATCH 32/32] FIX #20: [exception] invalid literal for int() --- gitstatus.py | 2 +- test_gitstatus.py | 63 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/gitstatus.py b/gitstatus.py index c23ec137..34b3550d 100755 --- a/gitstatus.py +++ b/gitstatus.py @@ -114,7 +114,7 @@ def parse_ahead_behind(branch): for part in branch[branch.rindex('[') + 1:-1].split(','): if 'ahead' in part: ahead = int(part.replace('ahead ', '')) - else: + elif 'behind' in part: behind = int(part.replace('behind ', '')) return ahead, behind diff --git a/test_gitstatus.py b/test_gitstatus.py index 182ade4f..8ca65b97 100644 --- a/test_gitstatus.py +++ b/test_gitstatus.py @@ -723,6 +723,64 @@ def git_repo_in_rebase(): 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 # ---------- @@ -915,3 +973,8 @@ def test_gitstatus_rebasing(git_repo_in_rebase): 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'