Enforce git branch policies - ruby

I'm trying to enforce a company policy, taking these assumptions:
There are only 3 available upstream branches: master, version/* and hotfix/*.
Master branch accepts only non-forwarded merge commits.
Version and Hotfix branches accept only fast-forward/rebased commits.
Master branch must only be merged into from Version or Hotfix branches.
Version and Hotfix branches must diverge from Master branch directly.
So far this is what I come up with:
#!/usr/bin/env ruby
# Encoding: utf-8
$oldrev, $newrev, $refname = STDIN.read.split(" ")
$real_refname = `git rev-parse --abbrev-ref #{$refname} 2> /dev/null`.strip
$merge_commits = `git rev-list --merges #{$oldrev}..#{$newrev} 2> /dev/null`.strip
$parent_commit = `git rev-parse #{$newrev}\^1`
$ancestor_branch = `git show-branch | grep '*' | grep -v '#{$real_refname}' | head -n1 | sed 's/.*\[\(.*\)\].*/\1/' | sed 's/[\^~].*//'`
puts "Enforcing Policies... \n(#{$real_refname}) (#{$oldrev[0,6]}) (#{$newrev[0,6]})"
$errors = []
def check_branch_policy()
$errors.push "Branch #{$real_refname}: Only Version, Hotfix and Master branches are allowed to be pushed upstream." if !$real_refname.match(/^(version\/[1-9.]+|hotfix\/[1-9.]+|master)/)
$errors.push "Branch #{$real_refname}: Master branch accepts only non-forwarded merge commits." if $real_refname.match('master') && (!$merge_commits.match($newrev) || !$parent_commit.match($oldrev))
$errors.push "Branch #{$real_refname}: Version and Hotfix branches accept only fast-forward/rebased commits." if !$real_refname.match('master') && !$merge_commits.empty?
$errors.push "Branch #{$real_refname}: Version and Hotfix branches must diverge from Master branch directly." if !$real_refname.match('master') && !$ancestor_branch[4,6].match('master')
false
end
check_branch_policy
unless $errors.empty?
puts '[POLICY] Invalid git branch rules.'
$errors.each { |error| puts "# #{error}" }
exit 1
end
A few issues though:
First, I'd be glad for a general code review. I'm not a rubyist whatsoever, and I just patched around things I found on the web. So the code is probably pretty bad.
Is there an easier way to enforce the "Master branch accepts only non-forwarded merge commits."?
sed and grep doesn't seem to play well with git hooks, so I basically need an alternative to the current $ancestor_branch command. Didn't come up with anything yet.
When first pushing a branch, $real_refname doesn't work - it can't seem to abbrev-ref properly.
I can't seem to find a way to enforce "Master branch must only be merged into from Version or Hotfix branches." yet. Any ideas?
EDIT #1 - 25.05.14
After tinkering around a little bit I got to this:
#!/usr/bin/env ruby
# Encoding: utf-8
oldrev, newrev, refname = STDIN.read.split(" ")
short_refname = refname[11..-1]
merge_commits = `git rev-list --merges #{oldrev}..#{newrev}`.strip
unique_revs = `git rev-list --all --not $(git rev-list --all ^#{newrev})`
missed_revs = `git rev-list #{oldrev}..#{newrev}`
puts "Enforcing Policies... \n(#{short_refname}) (#{oldrev[0,6]}) (#{newrev[0,6]})"
def check_branch_policy(oldrev,newrev,short_refname,merge_commits,unique_revs,missed_revs)
errors = []
errors << "Only Version, Hotfix and Master branches are allowed to be pushed upstream." if
!short_refname[/^(version\/[1-9.]+|hotfix\/[1-9.]+|master)/]
if short_refname['master']
# Master should have only one unique commit each time - the merge commit (newrev).
errors << "Master branch accepts only non-forwarded merge commits, one at a time." if
!merge_commits[newrev] && missed_revs.count > 2
else
# If not empty, it means there's a merge commit - whereas there shouldn't be.
errors << "Version and Hotfix branches accept only fast-forward/rebased commits." if
!merge_commits.empty?
# If not equal, it means at least one commit is reachable from another ref - meaning it was diverged.
errors << "Version and Hotfix branches must diverge from Master branch directly." if
!unique_revs[missed_revs]
end
errors
end
errors = check_branch_policy(oldrev,newrev,short_refname,unique_revs,missed_revs)
unless errors.empty?
puts '[POLICY] Invalid git branch rules.'
errors.each { |error| puts "# Branch #{short_refname}: #{error}" }
exit 1
end
More questions arose though:
Is there a way to serve the local variables without calling them in the method? Otherwise the script throws an error.
I managed to find a way to retrieve the short_refname, but it's not so elegant. I read somewhere I can use short_refname = refname.chomp("refs/heads/") but it doesn't seem to work. Help?
I found a way (clever? too complex? go figure) to find if a branch has diverged where it shouldn't have but this brings two issues - I can't get all the refs from the hook. --stdin flag doesn't seem to cut it. Further, the exclude flag (^some_ref) doesn't work inside the hook, whereas in the terminal it works fine. Ideas?
Assuming I move this script to update hook, how can I get the refnames? The web sources weren't so clear so far...

Lets first focus on the ruby part:
There is hardly ever a reason to use global variables in ruby. And in a script they are in a "global" scope anyway => get rid of the preceding $ in variable names
In this code:
$errors = []
def check_branch_policy()
$errors.push "Branch #{$real_refname}: Only Version, Hotfix and Master branches are allowed to be pushed upstream." if !$real_refname.match(/^(version\/[1-9.]+|hotfix\/[1-9.]+|master)/)
$errors.push "Branch #{$real_refname}: Master branch accepts only non-forwarded merge commits." if $real_refname.match('master') && (!$merge_commits.match($newrev) || !$parent_commit.match($oldrev))
$errors.push "Branch #{$real_refname}: Version and Hotfix branches accept only fast-forward/rebased commits." if !$real_refname.match('master') && !$merge_commits.empty?
$errors.push "Branch #{$real_refname}: Version and Hotfix branches must diverge from Master branch directly." if !$real_refname.match('master') && !$ancestor_branch[4,6].match('master')
false
end
check_branch_policy
It's bad style to write a method (or a function) which just works on a global object created only for this purpose. You might as well just remove the method definition, because it does nothing here. This is not particular "ruby style" thing but applies to programming in general. The better solution is to just create the object inside the method and return it. I also don't like these long unreadable lines. So in total would probably structure it more like this:
def check_branch_policy
errors = []
errors << "Only Version, Hotfix and Master branches are allowed to be pushed upstream." if
!real_refname[/^(version\/[1-9.]+|hotfix\/[1-9.]+|master)/]
if real_refname['master']
errors << "Master branch accepts only non-forwarded merge commits." if
!merge_commits[newrev] || !parent_commit[oldrev]
else
errors << "Version and Hotfix branches accept only fast-forward/rebased commits." if
merge_commits.empty?
errors << "Version and Hotfix branches must diverge from Master branch directly." if
!ancestor_branch[4, 6]['master']
end
errors
end
Even though the messages may be less neatly aligned here, I think it's an improvement that one can better see the conditions which should hold in each case. Note that I used the ruby idoms << instead of .push and [] instead of .match. I also left the Branch #{real_refname}: prefix out, it can be just as well in your error output loop if its always the same.
Also there is hardly a reason to rely on grep and sed when you have the power of ruby at hand.
As for the git part:
What you're trying to do is certainly possible, but I guess some try and error is needed. So I can't give you a working solution out of the hand. Some remarks though:
I think a better way to get a short symbolic ref in ruby is
`git symbolic-ref #{refname}\`[/[^\/]*$/].chomp
or even
`git symbolic-ref --short #{refname}`
you can try if that works more reliable than git rev-parse --abbrev-ref. Furthermore your variable real_refname is badly named. The 'real' ref name sounds like it would actually be the SHA1 hash. Probably short_refname would be better.
Since you're reading the refs from stdin I guess that you use a pre-receive git hook? But in this case you've clearly a bug, because there might be several branches updated in one push. You should either iterate over stdin or use the update hook
git show-branch is a porcelain command, i.e. it shouldn't be used for scripting because the output is meant for users. I think Junio did some pretty neat stuff in his pre-rebase.sample. Maybe you can get some ideas from there how to do it with plumbing commands.
I used to write even simple hooks in ruby, but I learned over the years that bash is also quite capable. So unless your hook gets really complex you might just start with bash.

Related

Implement git branch --contains with rugged library

I'm working with a ruby script that execute the following git command on a given git repository.
branches = `git branch -a --contains #{tag_name}`
This approach has some drawbacks with command output (that may change in different git versions) and is subject to git binary version on the hosting machine, so I was trying to see if it's possible to replace that command using rugged but I wasn't able to find anything similar to that.
Maybe in rugged there's no way to implement --contains flag, but I think it should be pretty easy to implement this behavior:
Given any git commit-ish (a tag, a commit sha, etc.) how to get (with rugged) the list of branches (both local and remote) that contains that commit-ish?
I need to implement something like github commit show page, i.e. tag xyz is contained in master, develop, branch_xx
Finally solved with this code:
def branches_for_tag(tag_name, repo_path = Dir.pwd)
#branches ||= begin
repo = Rugged::Repository.new(repo_path)
# Convert tag to sha1 if matching tag found
full_sha = repo.tags[tag_name] ? repo.tags[tag_name].target_id : tag_name
logger.debug "Inspecting repo at #{repo.path}, branches are #{repo.branches.map(&:name)}"
# descendant_of? does not return true for it self, i.e. repo.descendant_of?(x, x) will return false for every commit
# #see https://github.com/libgit2/libgit2/pull/4362
repo.branches.select { |branch| repo.descendant_of?(branch.target_id, full_sha) || full_sha == branch.target_id }
end
end

GIT post-receive multiple branch deployment

I have a development and production folder on the same server and 1 repo behind them to push to both folders depending on the branch that is pushed. I would like the development folder to be deployed to when develop is pushed to the repo and the production folder when master is pushed. I have an edited ruby post-receive file I found on a different site but I am new to ruby and can't seem to figure out why it isn't pushing to either folder.
#!/usr/bin/env ruby
# post-receive
from, to, branch = ARGF.read.split " "
if (branch =~ /^master/)
puts "Received branch #{branch}, deploying to production."
deploy_to_dir = File.expand_path('/var/www/html/production')
`GIT_WORK_TREE="#{deploy_to_dir}" git checkout -f master`
puts "DEPLOY: master(#{to}) copied to '#{deploy_to_dir}'"
exit
∂
elsif (branch =~ /^develop/)
puts "Received branch #{branch}, deploying to development."
deploy_to_dir = File.expand_path('/var/www/html/development')
`GIT_WORK_TREE="#{deploy_to_dir}" git checkout -f develop`
puts "DEPLOY: develop(#{to}) copied to '#{deploy_to_dir}'"
exit
end
Any help on this post-receive or a replacement would be appreciated.
If you don't mind shell script instead of Ruby then these people have solved the same problem.
Git post-receive hook to checkout each branch to different folders?
Perhaps a little late to the party on this, however maybe it will help others that are trying to find a solution in Ruby. Here is a working example (modified from this source which got me sorted):
#!/usr/bin/env ruby
# post-receive
# 1. Read STDIN (Format: "from_commit to_commit branch_name")
from, to, branch = ARGF.read.split " "
# 2. Only deploy if staging or master branch was pushed
if (branch =~ /staging$/) == nil && (branch =~ /master$/) == nil
puts "Received branch #{branch}, not deploying."
exit
end
# 3. Copy files to deploy directory(Path to deploy is relative to the git bare repo: e.g. website-root/repos)
if (branch =~ /staging$/)
deploy_to_dir = File.expand_path('../path-to-staging-deploy.com')
`GIT_WORK_TREE="#{deploy_to_dir}" git checkout -f staging`
puts "DEPLOY: staging(#{to}) copied to '#{deploy_to_dir}'"
elsif (branch =~ /master$/)
deploy_to_dir = File.expand_path('../path-to-master-deploy.com')
`GIT_WORK_TREE="#{deploy_to_dir}" git checkout -f master`
puts "DEPLOY: master(#{to}) copied to '#{deploy_to_dir}'"
else
puts "Received branch #{branch}, not deploying."
exit
end
I'm new to Ruby so there is probably a better way to write this, but it is working as expected - as far as I can see.

"git branch --merged <sha>" via Rugged libgit2 bindings?

Is there any way to get the same information as the native git command
git branch --merged <sha>
via the Rugged libgit2 bindings for Ruby?
What that git commit is doing is looking at each branch and checking whether the merge-base between the branch and the commit you've given (or HEAD if none) corresponds to the one of the branch.
If they match, it's merged; if they don't, it's not. You can do this loop in ruby fairly easily
repo.branches.each(:local) # look only at local branches
.map { |b|
tgt = b.resolve.target # look at what the branch is pointing to
# and check if the target commit is included in the history of HEAD
merged = repo.merge_base(repo.head.target, tgt) == tgt.oid
[b.name, merged]
} # this will give a list with the name and whether the branch is merged
.keep_if { |name, merged| merged } # keep only the ones which are merged
.map(&:first) # get the name
You could have an merged_list << b.name if merged in the first block and make it hang off of the each, but I like composing streams of data.
You can also change whether to use :local, :remote or both for the branches depending on your need. And you can also change repo.head.target to whatever id you want to compare against.

get the latest commit where a file changed

My task at hand is to figure out, what is the commit id of the last commit, where a specific file changed. I'm using ruby / rugged. The only solution I came up with is to walk over all commits, search for the file in the tree associated with the commit for that file and compare that files oid with the oid of the file from the first (latest) commit:
def commit_oid commit, file
commit.tree.walk( :postorder ) { | root, obj |
return obj[ :oid ] if "#{root}#{obj[ :name ]}" == file
}
raise "\'#{file}\' not found in repository"
end
def find_last_commit file
johnny = Rugged::Walker.new( get_repository )
johnny.push get_repository.head.target
oid = commit_oid johnny.first, file
old_commit = johnny.first.oid
johnny.each do | commit |
new_oid = commit_oid commit, file
return old_commit if new_oid != oid
old_commit = commit.oid
end
old_commit
end
This works but seems to be quit complicated. There must be an easier ways to get the information, "what changed with a commit". Is there an easier, more straight forward way to accomplish the same?
Running $ git log <file> will give you a reverse chronological log of only commits that altered the given file. $ git whatchanged <file> will do the same, adding a line with details of the change (i.e. mode change, change type). It's great for visual purposes, but not so much for scripting.
If you want just the hash of the most recent commit, the following will work well: $ git rev-list --max-count 1 HEAD <file>

Disable auto-completion of remote branches in Git Bash?

I'm working on a fairly large git repo with a couple of thousand (remote) branches. I am used to using auto-completion (using [TAB]) in the console (Git Bash in that case), so I unconsciously do that for git commands, too.
e.g. I'd type
git checkout task[TAB]
with the effect that the console stalls for often minutes. Is there a way to limit auto-completion to local branches only?
With Git 2.13 (Q2 2017), you can disable (some of) the branch completion.
git checkout --no-guess ...
# or:
export GIT_COMPLETION_CHECKOUT_NO_GUESS=1
See commit 60e71bb (21 Apr 2017) by Jeff King (peff).
(Merged by Junio C Hamano -- gitster -- in commit b439747, 01 May 2017)
As documented in contrib/completion/git-completion.bash now:
You can set the following environment variables to influence the behavior of the completion routines:
GIT_COMPLETION_CHECKOUT_NO_GUESS
When set to "1", do not include "DWIM" suggestions in git-checkout
completion (e.g., completing "foo" when "origin/foo" exists).
Note: DWIM is short for Do What I Mean, where a system attempts to anticipate what users intend to do, correcting trivial errors automatically rather than blindly executing users' explicit but potentially incorrect inputs.
completion: optionally disable checkout DWIM
When we complete branch names for "git checkout", we also complete remote branch names that could trigger the DWIM behavior. Depending on your workflow and project, this can be either convenient or annoying.
For instance, my clone of gitster.git contains 74 local "jk/*" branches, but origin contains another 147.
When I want to checkout a local branch but can't quite remember the name, tab completion shows me 251 entries. And worse, for a topic that has been picked up for pu, the upstream branch name is likely to be similar to mine, leading to a high probability that I pick the wrong one and accidentally create a new branch.
Note: "picked up for pu": see a What's cooking in git.git: it starts with:
Commits prefixed with '-' are only in 'pu' (proposed updates) while commits prefixed with '+' are in 'next'.
This is part of the Git Workflow Graduation process.
pu (proposed updates) is an integration branch for things that are not quite ready for inclusion yet
This patch adds a way for the user to tell the completion
code not to include DWIM suggestions for checkout.
This can already be done by typing:
git checkout --no-guess jk/<TAB>
but that's rather cumbersome.
The downside, of course, is that you no longer get completion support when you do want to invoke the DWIM behavior.
But depending on your workflow, that may not be a big loss (for instance, in git.git I am much more likely to want to detach, so I'd type "git checkout origin/jk/<TAB>" anyway).
I'm assuming that you are using the git-completion.bash script, and that you only care about git checkout.
To accomplish this, I just changed one line in the definition of the _git_checkout () function in git-completion.bash:
< __gitcomp_nl "$(__git_refs '' $track)"
---
> __gitcomp_nl "$(__git_heads '' $track)"
My understanding is that this only affects the tab-completion action (because of its location within the * case of the switch-case statement).
If you installed git-completion via homebrew, it's located here:
/usr/local/etc/bash_completion.d/git-completion.bash
Following erik.weathers' answer above, I made the following change so autocompletion can work for both local and remote based on the current prefix. By default, it'll only search local, but if I specify origin/… it'll know I want to search remote branches too.
In the _git_checkout () method, change
__gitcomp_nl "$(__git_refs '' $track)"
to:
# only search local branches instead of remote branches if origin isn't specified
if [[ $cur == "origin/"* ]]; then
__gitcomp_nl "$(__git_refs '' $track)"
else
__gitcomp_nl "$(__git_heads '' $track)"
fi
Of course, you can change origin to something else or you can have it search through through a list of remote prefixes if you have more than 1.
You can hack /etc/bash_completion.d/git
You'll need to edit __git_refs ()
Note that the change in behaviour will apply every where (so even with git push/pull where you might not want it to). You could of course, make a copy of the function or pass an extra parameter, but I leave that to you
You could think that you just the local branches with the alias co and all the branches with the complete command checkout.
You could perform the following. In your .bashrc, you redefine the _git_checkout() function. You let this function unchanged, except the end:
if [ $command -eq "co" ]; then
__gitcomp "$(__git_refs_local '' $track)"
else
__gitcomp "$(__git_refs '' $track)"
fi
Then, you just have to define a new function, __git_refs_local, where you remove the remote stuff.
Carey Metcalfe wrote a blog post containing a solution that also edits the auto-completion function, but with slightly newer code than other answers. He also defines an alias checkoutr that keeps the old auto-complete behavior in case it’s ever needed.
In short, first create the checkoutr alias with this command:
git config --global alias.checkoutr checkout
Then find git-completion.bash, copy the _git_checkout function into your shell’s RC file so that it gets redefined, and inside that function, replace this line:
__git_complete_refs $track_opt
with the following lines:
if [ "$command" = "checkoutr" ]; then
__git_complete_refs $track_opt
else
__gitcomp_direct "$(__git_heads "" "$cur" " ")"
fi
See the blog post for more details and potential updates to the code.
Modifying $(brew --prefix)/etc/bash_completion.d/git-completion.bash is not a good idea because it will be overwritten every time you update Git through Homebrew.
Combining all the answers I overwrite only _git_checkout function from the completion file in my .bash_profile after sourcing the completion file:
_git_checkout ()
{
__git_has_doubledash && return
case "$cur" in
--conflict=*)
__gitcomp "diff3 merge" "" "${cur##--conflict=}"
;;
--*)
__gitcomp "
--quiet --ours --theirs --track --no-track --merge
--conflict= --orphan --patch
"
;;
*)
# check if --track, --no-track, or --no-guess was specified
# if so, disable DWIM mode
local flags="--track --no-track --no-guess" track=1
if [ -n "$(__git_find_on_cmdline "$flags")" ]; then
track=''
fi
# only search local branches instead of remote branches if origin isn't
# specified
if [[ $cur == "origin/"* ]]; then
__gitcomp_nl "$(__git_refs '' $track)"
else
__gitcomp_nl "$(__git_heads '' $track)"
fi
;;
esac
}
I'm not using Git Bash myself, but if this is the same as mentioned in
http://tekrat.com/2008/04/30/bash-autocompletion-git-super-lazy-goodness/, you should be able to replace git branch -a with a plain git branch in
_complete_git() {
if [ -d .git ]; then
branches=`git branch -a | cut -c 3-`
tags=`git tag`
cur="${COMP_WORDS[COMP_CWORD]}"
COMPREPLY=( $(compgen -W "${branches} ${tags}" -- ${cur}) )
fi
}
complete -F _complete_git git checkout
(in your .profile or similar) and get what you want.
FWW here is a hack to __git_complete_refs that does the trick
__git_complete_refs ()
{
local remote track pfx cur_="$cur" sfx=" "
while test $# != 0; do
case "$1" in
--remote=*) remote="${1##--remote=}" ;;
--track) track="yes" ;;
--pfx=*) pfx="${1##--pfx=}" ;;
--cur=*) cur_="${1##--cur=}" ;;
--sfx=*) sfx="${1##--sfx=}" ;;
*) return 1 ;;
esac
shift
done
echo cur_ $cur_ > a
if [[ $GIT_COMPLETION_CHECKOUT_NO_GUESS != 1 || $cur_ == "origin"* ]]; then
__gitcomp_direct "$(__git_refs "$remote" "$track" "$pfx" "$cur_" "$sfx")"
else
__gitcomp_direct "$(__git_heads "" "$cur_")"
fi
}

Resources