lib/gitdocs/repository.rb
# -*- encoding : utf-8 -*-
require 'find'
# rubocop:disable ClassLength
# This class is long, but at the moment everything in it seems to be
# appropriate.
# Wrapper for accessing the shared git repositories.
# Rugged or Grit will be used, in that order of preference, depending
# upon the features which are available with each option.
#
# @note If a repository is invalid then query methods will return nil, and
# command methods will raise exceptions.
module Gitdocs
class Repository
attr_reader :invalid_reason
class InvalidError < StandardError; end
class FetchError < StandardError; end
class MergeError < StandardError; end
# Initialize the repository on the specified path. If the path is not valid
# for some reason, the object will be initialized but it will be put into an
# invalid state.
# @see #valid?
# @see #invalid_reason
#
# @param [String, Share] path_or_share
def initialize(path_or_share)
path = path_or_share
if path_or_share.respond_to?(:path)
path = path_or_share.path
@remote_name = path_or_share.remote_name
@branch_name = path_or_share.branch_name
end
@rugged = Rugged::Repository.new(path)
@grit = Grit::Repo.new(path)
Grit::Git.git_timeout = 120
@invalid_reason = nil
@commit_message_path = abs_path('.gitmessage~')
rescue Rugged::OSError
@invalid_reason = :directory_missing
rescue Rugged::RepositoryError
@invalid_reason = :no_repository
end
# Clone a repository, and create the destination path if necessary.
#
# @param [String] path to clone the repository to
# @param [String] remote URI of the git repository to clone
#
# @raise [RuntimeError] if the clone fails
#
# @return [Gitdocs::Repository]
def self.clone(path, remote)
FileUtils.mkdir_p(File.dirname(path))
# TODO: determine how to do this with rugged, and handle SSH and HTTPS
# credentials.
Grit::Git.new(path).clone({ raise: true, quiet: true }, remote, path)
repository = new(path)
fail("Unable to clone into #{path}") unless repository.valid?
repository
rescue Grit::Git::GitTimeout
raise("Unable to clone into #{path} because it timed out")
rescue Grit::Git::CommandFailed => e
raise("Unable to clone into #{path} because of #{e.err}")
end
# @return [String]
def root
return nil unless valid?
@rugged.path.sub(/.\.git./, '')
end
# @return [Boolean]
def valid?
!@invalid_reason
end
# @return [nil] if the repository is invalid
# @return [Array<String>] sorted list of remote branches
def available_remotes
return nil unless valid?
@rugged.branches.each_name(:remote).sort
end
# @return [nil] if the repository is invalid
# @return [Array<String>] sorted list of local branches
def available_branches
return nil unless valid?
@rugged.branches.each_name(:local).sort
end
# @return [nil] if there are no commits present
# @return [String] oid of the HEAD of the working directory
def current_oid
@rugged.head.target_id
rescue Rugged::ReferenceError
nil
end
# Is the working directory dirty
#
# @return [Boolean]
def dirty?
return false unless valid?
return Dir.glob(abs_path('*')).any? unless current_oid
@rugged.diff_workdir(current_oid, include_untracked: true).deltas.any?
end
# @return [Boolean]
def need_sync?
return false unless valid?
return false unless remote?
remote_oid != current_oid
end
# @param [String] term
# @yield [file, context] Gives the files and context for each of the results
# @yieldparam file [String]
# @yieldparam context [String]
def grep(term, &block)
@grit.git.grep(
{ raise: true, bare: false, chdir: root, ignore_case: true },
term
).scan(/(.*?):([^\n]*)/, &block)
rescue Grit::Git::GitTimeout
# TODO: add logging to record the error details
''
rescue Grit::Git::CommandFailed
# TODO: add logging to record the error details if they are not just
# nothing found
''
end
# Fetch all the remote branches
#
# @raise [FetchError] if there is an error return message
#
# @return [nil] if the repository is invalid
# @return [:no_remote] if the remote is not yet set
# @return [:ok] if the fetch worked
def fetch
return nil unless valid?
return :no_remote unless remote?
@rugged.remotes.each { |x| @grit.remote_fetch(x.name) }
:ok
rescue Grit::Git::GitTimeout
raise(FetchError, "Fetch timed out for #{root}")
rescue Grit::Git::CommandFailed => e
raise(FetchError, e.err)
end
# Merge the repository
#
# @raise [MergeError] if there is an error, it it will include the message
#
# @return [nil] if the repository is invalid
# @return [:no_remote] if the remote is not yet set
# @return [Array<String>] if there is a conflict return the Array of
# conflicted file names
# @return (see #author_count) if merged with no errors or conflicts
def merge
return nil unless valid?
return :no_remote unless remote?
return :ok unless remote_oid
return :ok if remote_oid == current_oid
last_oid = current_oid
@grit.git.merge(
{ raise: true, chdir: root },
"#{@remote_name}/#{@branch_name}"
)
author_count(last_oid)
rescue Grit::Git::GitTimeout
raise(MergeError, "Merge timed out for #{root}")
rescue Grit::Git::CommandFailed => e
# HACK: The rugged in-memory index will not have been updated after the
# Grit merge command. Reload it before checking for conflicts.
@rugged.index.reload
raise(MergeError, e.err) unless @rugged.index.conflicts?
mark_conflicts
end
# @return [nil]
# @return (see Gitdocs::Repository::Comitter#commit)
def commit
return unless valid?
Committer.new(root).commit
end
# Push the repository
#
# @return [nil] if the repository is invalid
# @return [:no_remote] if the remote is not yet set
# @return [:nothing] if there was nothing to do
# @return [String] if there is an error return the message
# @return (see #author_count) if pushed without errors or conflicts
def push
return unless valid?
return :no_remote unless remote?
return :nothing unless current_oid
return :nothing if remote_oid == current_oid
last_oid = remote_oid
@grit.git.push({ raise: true }, @remote_name, @branch_name)
author_count(last_oid)
rescue Grit::Git::CommandFailed => e
return :conflict if e.err[/\[rejected\]/]
e.err # return the output on error
end
# Get the count of commits by author from the head to the specified oid.
#
# @param [String] last_oid
#
# @return [Hash<String, Int>]
def author_count(last_oid)
walker = head_walker
walker.hide(last_oid) if last_oid
walker.reduce(Hash.new(0)) do |result, commit|
result["#{commit.author[:name]} <#{commit.author[:email]}>"] += 1
result
end
rescue Rugged::ReferenceError
{}
rescue Rugged::OdbError
{}
end
# @return [Hash{:merge,:push => Object}]
def synchronize(type)
result = { merge: nil, push: nil }
return result unless valid?
case type
when 'fetch'
fetch
when 'full'
commit
fetch
result[:merge] = merge
result[:push] = push
end
result
rescue Gitdocs::Repository::FetchError
result
rescue Gitdocs::Repository::MergeError => e
result[:merge] = e.message
result
end
# @param (see Gitdocs::Repository::Comitter#write_commit_message)
# @return [void]
def write_commit_message(message)
return unless valid?
Committer.new(root).write_commit_message(message)
end
# Excluding the initial commit (without a parent) which keeps things
# consistent with the original behaviour.
# TODO: reconsider if this is the correct behaviour
#
# @param [String] relative_path
# @param [Integer] limit the number of commits which will be returned
#
# @return [Array<Rugged::Commit>]
def commits_for(relative_path, limit)
# TODO: should add a filter here for checking that the commit actually has
# an associated blob.
commits = head_walker.select do |commit|
commit.parents.size == 1 && changes?(commit, relative_path)
end
# TODO: should re-write this limit in a way that will skip walking all of
# the commits.
commits.first(limit)
end
# @param [String] relative_path
#
# @return [Rugged::Commit]
def last_commit_for(relative_path)
head_walker.find { |commit| changes?(commit, relative_path) }
end
# @param [String] relative_path
# @param [String] oid
def blob_at(relative_path, ref)
@rugged.blob_at(ref, relative_path)
end
############################################################################
private
# @param [Rugged::Commit] commit
# @param [String] relative_path
#
# @return [Boolean]
def changes?(commit, relative_path)
commit.diff(paths: [relative_path]).size > 0 # rubocop:disable ZeroLengthPredicate
end
# @return [Boolean]
def remote?
@rugged.remotes.any?
end
# @return [nil]
# @return [String]
def remote_oid
branch = @rugged.branches["#{@remote_name}/#{@branch_name}"]
return unless branch
branch.target_id
end
# @return [Rugged::Walker]
def head_walker
walker = Rugged::Walker.new(@rugged)
walker.sorting(Rugged::SORT_DATE)
walker.push(@rugged.head.target)
walker
end
# @return [String]
def read_and_delete_commit_message_file
return 'Auto-commit from gitdocs' unless File.exist?(@commit_message_path)
message = File.read(@commit_message_path)
File.delete(@commit_message_path)
message
end
# @return [void]
def mark_empty_directories
Find.find(root).each do |path|
Find.prune if File.basename(path) == '.git'
if File.directory?(path) && Dir.entries(path).count == 2
FileUtils.touch(File.join(path, '.gitignore'))
end
end
end
# @return [Array<String>]
def mark_conflicts
# assert(@rugged.index.conflicts?)
# Collect all the index entries by their paths.
index_path_entries = Hash.new { |h, k| h[k] = [] }
@rugged.index.map do |index_entry|
index_path_entries[index_entry[:path]].push(index_entry)
end
# Filter to only the conflicted entries.
conflicted_path_entries =
index_path_entries.delete_if { |_k, v| v.length == 1 }
conflicted_path_entries.each_pair do |path, index_entries|
# Write out the different versions of the conflicted file.
index_entries.each do |index_entry|
filename, extension =
index_entry[:path].scan(/(.*?)(|\.[^\.]+)$/).first
author = ' original' if index_entry[:stage] == 1
short_oid = index_entry[:oid][0..6]
new_filename = "#{filename} (#{short_oid}#{author})#{extension}"
File.open(abs_path(new_filename), 'wb') do |f|
f.write(Rugged::Blob.lookup(@rugged, index_entry[:oid]).content)
end
end
# And remove the original.
FileUtils.remove(abs_path(path), force: true)
end
# NOTE: Let commit be handled by the next regular commit.
conflicted_path_entries.keys
end
# @param [Array<String>] *path
#
# @return [String]
def abs_path(*path)
File.join(root, *path)
end
end
end