lib/gitdocs/repository.rb in gitdocs-0.5.0 vs lib/gitdocs/repository.rb in gitdocs-0.6.0

- old
+ new

@@ -10,358 +10,375 @@ # 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. -class Gitdocs::Repository - attr_reader :invalid_reason +module Gitdocs + class Repository + attr_reader :invalid_reason - # 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, Configuration::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 + class InvalidError < StandardError; end + class FetchError < StandardError; end + class MergeError < StandardError; 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 + # 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 - # 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) + @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 - 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 + # 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) - # @return [String] - def root - return nil unless valid? - @rugged.path.sub(/.\.git./, '') - end + 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 [Boolean] - def valid? - !@invalid_reason - end + # @return [String] + def root + return nil unless valid? + @rugged.path.sub(/.\.git./, '') + end - # @return [nil] if the repository is invalid - # @return [Array<String>] sorted list of remote branches - def available_remotes - return nil unless valid? - Rugged::Branch.each_name(@rugged, :remote).sort - end + # @return [Boolean] + def valid? + !@invalid_reason + end - # @return [nil] if the repository is invalid - # @return [Array<String>] sorted list of local branches - def available_branches - return nil unless valid? - Rugged::Branch.each_name(@rugged, :local).sort - 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 there are no commits present - # @return [String] oid of the HEAD of the working directory - def current_oid - @rugged.head.target - rescue Rugged::ReferenceError - nil - 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 - # Is the working directory dirty - # - # @return [Boolean] - def dirty? - return false unless valid? + # @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 - return Dir.glob(abs_path('*')).any? unless current_oid - @rugged.diff_workdir(current_oid, include_untracked: true).deltas.any? - end + # Is the working directory dirty + # + # @return [Boolean] + def dirty? + return false unless valid? - # @return [Boolean] - def need_sync? - return false unless valid? - return false unless remote? + return Dir.glob(abs_path('*')).any? unless current_oid + @rugged.diff_workdir(current_oid, include_untracked: true).deltas.any? + end - return !!current_oid unless remote_branch # rubocop:disable DoubleNegation - remote_branch.tip.oid != current_oid - 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 + # @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 - # - # @return [nil] if the repository is invalid - # @return [:no_remote] if the remote is not yet set - # @return [String] if there is an error return the message - # @return [:ok] if the fetch worked - def fetch - return nil unless valid? - return :no_remote unless remote? + # 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 - "Fetch timed out for #{root}" - rescue Grit::Git::CommandFailed => e - e.err - end + @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 - # - # @return [nil] if the repository is invalid - # @return [:no_remote] if the remote is not yet set - # @return [String] if there is an error return the message - # @return [Array<String>] if there is a conflict return the Array of - # conflicted file names - # @return [:ok] if the merged with no errors or conflicts - def merge - return nil unless valid? - return :no_remote unless remote? + # 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 - return :ok unless remote_branch - return :ok if remote_branch.tip.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 - @grit.git.merge( - { raise: true, chdir: root }, - "#{@remote_name}/#{@branch_name}" - ) - :ok - rescue Grit::Git::GitTimeout - "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 - return 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 - # Commit the working directory - # - # @return [nil] if the repository is invalid - # @return [Boolean] whether a commit was made or not - def commit - return nil unless valid? + # 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 - # Do this first to allow the message file to be deleted, if it exists. - message = read_and_delete_commit_message_file + 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 - mark_empty_directories - - return false unless dirty? - - # Commit any changes in the working directory. - Dir.chdir(root) do - @rugged.index.add_all - @rugged.index.update_all + # 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 - @rugged.index.write - @grit.commit_index(message) - true - 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 [:ok] if committed and pushed without errors or conflicts - def push - return nil unless valid? - return :no_remote unless remote? + # @return [Hash{:merge,:push => Object}] + def synchronize(type) + result = { merge: nil, push: nil } + return result unless valid? - return :nothing if current_oid.nil? - return :nothing if remote_branch && remote_branch.tip.oid == current_oid - - @grit.git.push({ raise: true }, @remote_name, @branch_name) - :ok - 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 + 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 - rescue Rugged::ReferenceError - {} - rescue Rugged::OdbError - {} - end - # @param [String] message - def write_commit_message(message) - return unless message - return if message.empty? + # @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 - File.open(@commit_message_path, 'w') { |f| f.print(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 - # 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 && commit.diff(paths: [relative_path]).size > 0 + # @param [String] relative_path + # + # @return [Rugged::Commit] + def last_commit_for(relative_path) + head_walker.find { |commit| 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| commit.diff(paths: [relative_path]).size > 0 } - end + # @param [String] relative_path + # @param [String] oid + def blob_at(relative_path, ref) + @rugged.blob_at(ref, relative_path) + end - # @param [String] relative_path - # @param [String] oid - def blob_at(relative_path, ref) - @rugged.blob_at(ref, relative_path) - end + ############################################################################ - ############################################################################## + private - 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 - def remote? - @rugged.remotes.any? - end + # @return [Boolean] + def remote? + @rugged.remotes.any? + end - # HACK: This will return nil if there are no commits in the remote branch. - # It is not the response that I would expect but it mostly gets the job - # done. This should probably be reviewed when upgrading to the next version - # of Rugged. - # - # @return [nil] if the remote branch does not exist - # @return [Rugged::Remote] - def remote_branch - Rugged::Branch.lookup(@rugged, "#{@remote_name}/#{@branch_name}", :remote) - end + # @return [nil] + # @return [String] + def remote_oid + branch = @rugged.branches["#{@remote_name}/#{@branch_name}"] + return unless branch + branch.target_id + end - def head_walker - walker = Rugged::Walker.new(@rugged) - walker.sorting(Rugged::SORT_DATE) - walker.push(@rugged.head.target) - walker - end + def head_walker + walker = Rugged::Walker.new(@rugged) + walker.sorting(Rugged::SORT_DATE) + walker.push(@rugged.head.target) + walker + end - def read_and_delete_commit_message_file - return 'Auto-commit from gitdocs' unless File.exist?(@commit_message_path) + 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 + message = File.read(@commit_message_path) + File.delete(@commit_message_path) + message + end - def mark_empty_directories - Find.find(root).each do |path| # rubocop:disable Style/Next - Find.prune if File.basename(path) == '.git' - if File.directory?(path) && Dir.entries(path).count == 2 - FileUtils.touch(File.join(path, '.gitignore')) + 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 - end - def mark_conflicts - # assert(@rugged.index.conflicts?) + def mark_conflicts + # assert(@rugged.index.conflicts?) - # Collect all the index entries by their paths. - index_path_entries = Hash.new { |h, k| h[k] = Array.new } - @rugged.index.map do |index_entry| - index_path_entries[index_entry[:path]].push(index_entry) - end + # 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 } + # 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) + 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 - # And remove the original. - FileUtils.remove(abs_path(path), force: true) + # NOTE: Let commit be handled by the next regular commit. + + conflicted_path_entries.keys end - # NOTE: Let commit be handled by the next regular commit. - - conflicted_path_entries.keys - end - - def abs_path(*path) - File.join(root, *path) + def abs_path(*path) + File.join(root, *path) + end end end