Custom Search

Friday, February 25, 2011

Adding a VCS to zsh's vcs_info

Modern shells have features that let you add dynamically changing information to the shells prompt. Using these, it's fairly easy to add information from your version control software about your current working copy - if you're in one - to your prompt. Just google for prompt shell vcs and you'll probably get a hits on a couple of different pages with instructions (different instructions, naturally) for doing so.

While this is fine if you work with one VCS all - or even most - of the time, it doesn't really work very well for a consultant, who generally has to work with the VCS their clients are already using, and so changes VCS's on a regular basis. While modern DVCS's alleviate that to some degree by being able to interoperate with other VCSs, they generally don't get them all. I have working copies from svn, perforce, git, mercurial, fossil, and even a few still using CVS on my local machine.

Fortunally, zsh has a solution for this. Among it's contributed modules is vcs_info, which include a command that figures out if you're in the workspace of some VCS, and extracts the info from that VCS into a fixed set of variables you can use to set your prompt. Again, setting this up for a supported VCS is fairly easy, and if you googled for prompt zsh vcs earlier you probably found a couple of pages with instructions (still different, though) on this.

Fossil - which is what I'm using most now - wasn't among the (actually rather impressive) list of supported VCSs. Nor did google turn up instructions on adding one. Having gathered that information from reading the source, I hope to save those following in my footsteps by documenting the steps - at least for the current vcs_info implementation (in zsh 4.3.11).

Personally, I prefer using zsh's RPROMPT feature for such information, which places it on the right side of the screen instead of the left. This keeps the normal prompt uncluttered and short. Here's a screen capture of vcs_info at work, showing the changes to the RPROMPT:

Following along (on the right), the first line just has a directory name. The command opens a working copy in that directory, and the RPROMPT changes to add the vcs text |fossil:trunk@ab7d5 in green. That tells me I'm in a fossil working copy, on the trunk with change set ab7d5 checked out. I create a new file and use the fossil add command to add it to the repository. While the RPROMPT has the same text, the vcs info changes color to red, telling me that I've got uncommitted changes in the workspace. That's traffic color codes - green means Go on and do destructive things here, whereas red means Stop and save the changes before doing destructive things here.

Checking that change in changes the vcs part of the RPROMPT color back to green, but with a different change checked out after the @-sign. I now create another change, and check it in on a new branch. The RPROMPT obligingly changes the vcs text to |fossil:test@8f9d3, showing the new branch and change set. I update back to the trunk - and the RPROMPT follows - and merge that change to show off the last feature. After I've started the merge, the vcs part of RPROMPT becomes |fossil@merging:trunk@108f1 - and in red. This tells me that the uncommitted changes are there because they're from a merge command. I commit those, and the RPROMPT chanegs back to green and loses the merging indicator.

Detecting the VCS

For vcs_info to work with your VCS, you need to provide two shell scripts. One looks up the directory tree to see if it can find the root directory of a working copy checked out from that VCS. Fortunately, there are vcs_info tools to make this easy, so that the script for that is rather short. It's name is VCS_INFO_detect_vcs, where vcs is the command to run the VCS in question. So for fossil, it has to be named VCS_INFO_detect_fossil, and contains:
## vim:ft=zsh
## fossil support by: Mike Meyer <mwm@mired.org>
## Distributed under the same BSD-ish license as zsh itself.

setopt localoptions NO_shwordsplit

[[ $1 == '--flavours' ]] && return 1

VCS_INFO_check_com ${vcs_comm[cmd]} || return 1
vcs_comm[detect_need_file]=_FOSSIL_
VCS_INFO_bydir_detect . || return 1

return 0

After the comment header, the script checks to see if it's being asked about flavours, which fossil doesn't support, so it exist with a 1. flavours are used for DVCSs that can talk to different servers to indicate which flavour of server this working copy is cloned from. If fossil had such flavors, it would print out a space-separated list of flavors and then exit with a 0. In the body, it would then overwrite vcs_comm[overwrite_name] with the flavor name, which it presumably figures out by sniffing through the on-disk VCS data. You can see this at work in either VCS_INFO_detect_git or VCS_INFO_detect_hg.

The next three lines do the actual work. The array $vcs_comm is used to pass around information about the VCS. In particular, ${vcs_comm[cmd]} contains the command name, and VCS_INFO_check_com will try and find that command. If it fails, the script will fail as a result.

VCS_INFO_bydir_detect does most of the work. It will walk up the directory tree starting at the current working directory, looking for a directory whose name matches it's argument (since most VCS's store their metadata in a directory at the root of the working copy). If vcs_comm[detect_need_file] is set, then said directory needs to contain the file name it contains as well. Since fossil only creates the file _FOSSIL_ in the root directory, it looks in the current directory (.) for the file _FOSSIL. VCS_INFO_bydir_detect will fill in yet more of vcs_comm and exit with success if it finds this, otherwise it exists with failure. If it exists with failure, our script does likewise.

The last line is our exiting with success, indicating that the current directory is in a fossil working directory, and that vcs_com has been filled out.

Getting the VCS information

The other half of adding support for a VCS is the VCS_INFO_get_data_vcs script. For fossil, this file is VCS_INFO_get_data_fossil, and can be found in it's entirety in the mired-in-code repository.

The crucial step in VCS_INFO_get_data-fossil is the invocation of VCS_INFO_formats near the end. This is used to pass the information from the VCS to vcs_info so it can set the shell variable used in your prompt correctly:
VCS_INFO_formats "$action" "${fsbranch}" \
    "${fsinfo[local_root]}" '' "$changed" "${fsrev}" \
    "${fsinfo[repository]}"
return 0
Those variables are, in order,
  1. Any action currently going on (i.e. - the merging text in the RPROMPT).
  2. The value to use for the branch name (which actually includes the revision information - more on that later).
  3. The root directory for this repository
  4. Whether or not there are unstaged changes (this is a git thing, not used by most VCS's, and empty here).
  5. Whether or not there are any uncommitted changes (which triggers the change to red in my RPROMPT).
  6. The revision number of the checked out revision.
  7. Whatever miscellaneous information is felt to be appropriate here. For fossil, I use the repository file.
The job of the VCS_INFO_get_data_vcs script is to gather that data. Fossil provides all the data vcs_info needs via the status command, so the script starts by running that and save the results in an array:
${vcs_comm[cmd]} status | \
     while IFS=: read a b
     do fsinfo[${a//-/_}]="${b## #}"; done
fshash=${fsinfo[checkout]%% *}
changed=${(Mk)fsinfo:#(ADDED|EDITED|DELETED|UPDATED)*}
merging=${(Mk)fsinfo:#*_BY_MERGE*}
if [ -n "$merging" ]; then
   action="merging"
fi
The output of the status command is fed to a while loop that loads the values into the fsinfo array, turning - into _ in the keys and squeezing spaces from the values. It then pulls some values that need to be modified from that: the hash value has the date stripped off the end (but keep the entire 40-digit hash for the user!), whether or not there are changed files is determined by looking for ADDED|EDITED|DELETED|UPDATED in the output, and the action - whether or not there is a merge - is done by looking for *_BY_MERGE, which causes us to set action to merging if present.

The next step is to build the revision string. The fossil code adds the zstyle variable fsrevformat to let the user control the revision string format in one place - where by control, I mean decide how many digits they want to see. The revision data shows up in a number of places, but it will always be formated as per fsrevformat, unless the user overrides that somehow:
# Build the revision display
zstyle -s ":vcs_info:${vcs}:${usercontext}:${rrn}" \
    fsrevformat revformat || revformat="%.10h"

hook_com=( "hash" "${fshash}" )

if VCS_INFO_hook 'set-fsrev-format' "${revformat}"
then
    zformat -f fsrev "${revformat}" \
        "h:${hook_com[hash]}"
else
    fsrev=${hook_com[rev-replace]}
fi

hook_com=()
The first part of this fetches the value of the fsrevformat variable for the current context (you'll need to read the zsh documentation for an explanation of contexts - I'll just say it lets variables change values depending on what's going on), and sets it to a default of %.10h, which means use the first 10 digits of the hash.

The hook_com array is used to communicate values to user-provided hooks, if present. For setting the revision value here, hook_com gets the full hash value with the key hash. The script checks for the hook for setting this variable - named set-fsrev-format - and either set the value ourselves with the zformat command if there isn't one, or invoke the hook if it exists. Finally, the script clear the hook_com array for later use.

The last step is very similar, but not always followed: set up the branch information:
# Now build the branch display
zstyle -s ":vcs_info:${vcs}:${usercontext}:${rrn}" \
    branchformat fsbranch || fsbranch="%b:%r"

hook_com=( branch "${fsinfo[tags]%%, *}" \
           revision "${fsrev}" )

if VCS_INFO_hook 'set-branch-format' "${fsbranch}"
then
    zformat -f fsbranch "${fsbranch}" \
        "b:${hook_com[branch]}" \
        "r:${hook_com[revision]}"
else
    fsbranch=${hook_com[branch-replace]}
fi

hook_com=()
The logic here is identical to the logic for setting the revision format:
  1. Get the appropriate variable - in this case branchformat, which I believe should be used by all the vcs_info facilities. The default value of %b:%r is documented by in the zshcontrib manual page.
  2. Set up hook_com with the branch and revision information. Note that it gets the revision number as previously formatted as well as the branch name.
  3. Check for a hook, and either run it or use zformat to format the branch text.
  4. Clear out hook_com to avoid confusing anyone that might follow us.
After this is done, we just run the VCS_INFO_formats function with the data we've collected filled into the right spots.

My zsh configuration

If you just use this, you won't get the results I displayed above. For that, you need the following set in your running zsh:

# Now reset the prompt to get colors
colors

# Turn on and configure the version control system information
autoload -Uz vcs_info
precmd () { vcs_info }
zstyle ':vcs_info:*' get-revision true
zstyle ':vcs_info:*' check-for-changes true
zstyle ':vcs_info:*' formats '%u%c|%s:%b'
zstyle ':vcs_info:*' actionformats '%c%u|%s@%a:%b'
zstyle ':vcs_info:*' branchformat '%b@%r'
zstyle ':vcs_info:*' unstagedstr "%{$fg_no_bold[red]%}"
zstyle ':vcs_info:*' stagedstr "%{$fg_no_bold[yellow]%}"
zstyle ':vcs_info:*' enable fossil hg svn git cvs # p4 off, but must be last.

# vcs-specific formatting...
zstyle ':vcs_info:hg*:*' hgrevformat "%r"
zstyle ':vcs_info:fossil:*' fsrevformat '%.5h'
# Silly git doesn't honor branchformat
zstyle ':vcs_info:git*:*' formats '%c%u|%s@%a:%b@%.5i'
zstyle ':vcs_info:git*:*' actionformats '%c%u|%s@%a:%b@%.5i'

# now use the blasted colors!
setopt PROMPT_SUBST
RPROMPT='%{$fg_no_bold[magenta]%}%~%{$fg_no_bold[green]%}${vcs_info_msg_0_}%{$reset_color%}'
This turns on colors by name, then configures the general vcs_info appearance. There's a little VCS-specific tweaking for hg, fossil (their revision format variables) and git (which ignores branchformat for some reason). Finally, just set RPROMPT to use all this.