module Gitdocs class Runner include ShellTools attr_reader :root, :listener def self.start_all(shares) runners = shares.map { |share| Runner.new(share) } runners.each(&:run) runners end def initialize(share) @share = share @root = share.path.sub(%r{/+$}, '') if share.path @polling_interval = share.polling_interval @icon = File.expand_path('../../img/icon.png', __FILE__) end SearchResult = Struct.new(:file, :context) def search(term) return [] if term.empty? results = [] if result_test = sh_string("git grep -i #{ShellTools.escape(term)}") result_test.scan(/(.*?):([^\n]*)/) do |(file, context)| if result = results.find { |s| s.file == file } result.context += ' ... ' + context else results << SearchResult.new(file, context) end end end results end def run return false unless self.valid? @show_notifications = @share.notification @current_remote = @share.remote_name @current_branch = @share.branch_name @current_revision = sh_string('git rev-parse HEAD') Guard::Notifier.turn_on if @show_notifications mutex = Mutex.new info('Running gitdocs!', "Running gitdocs in `#{@root}'") # Pull changes from remote repository syncer = proc do EM.defer(proc do mutex.synchronize { sync_changes } end, proc do EM.add_timer(@polling_interval) do syncer.call end end) end syncer.call # Listen for changes in local repository EM.defer(proc do listener = Guard::Listener.select_and_init(@root, watch_all_modifications: true) listener.on_change do |directories| directories.uniq! directories.delete_if { |d| d =~ /\/\.git/ } unless directories.empty? EM.next_tick do EM.defer(proc do mutex.synchronize { push_changes } end, proc {}) end end end listener.start end, proc { EM.stop_reactor }) end def clear_state @state = nil end def sync_changes out, status = sh_with_code("git fetch --all && git merge #{@current_remote}/#{@current_branch}") if status.success? changes = get_latest_changes unless changes.empty? author_list = changes.reduce(Hash.new { |h, k| h[k] = 0 }) { |h, c| h[c['author']] += 1; h }.to_a.sort { |a, b| b[1] <=> a[1] }.map { |(name, count)| "* #{name} (#{count} change#{count == 1 ? '' : 's'})" }.join("\n") info("Updated with #{changes.size} change#{changes.size == 1 ? '' : 's'}", "In `#{@root}':\n#{author_list}") end push_changes elsif out[/CONFLICT/] conflicted_files = sh('git ls-files -u --full-name -z').split("\0") .reduce(Hash.new { |h, k| h[k] = [] }) do|h, line| parts = line.split(/\t/) h[parts.last] << parts.first.split(/ /) h end warn('There were some conflicts', "#{conflicted_files.keys.map { |f| "* #{f}" }.join("\n")}") conflicted_files.each do |conflict, ids| conflict_start, conflict_end = conflict.scan(/(.*?)(|\.[^\.]+)$/).first ids.each do |(mode, sha, id)| author = ' original' if id == '1' system("cd #{@root} && git show :#{id}:#{conflict} > '#{conflict_start} (#{sha[0..6]}#{author})#{conflict_end}'") end system("cd #{@root} && git rm #{conflict}") || fail end push_changes elsif sh_string('git remote').nil? # no remote to pull from # Do nothing, no remote repo yet else error('There was a problem synchronizing this gitdoc', "A problem occurred in #{@root}:\n#{out}") end end def push_changes message_file = File.expand_path('.gitmessage~', @root) if File.exist? message_file message = File.read message_file File.delete message_file else message = 'Auto-commit from gitdocs' end sh 'find . -type d -regex ``./[^.].*'' -empty -exec touch \'{}/.gitignore\' \;' sh 'git add .' sh "git commit -a -m #{ShellTools.escape(message)}" unless sh('git status -s').strip.empty? if @current_revision.nil? || sh('git status')[/branch is ahead/] out, code = sh_with_code("git push #{@current_remote} #{@current_branch}") if code.success? changes = get_latest_changes info("Pushed #{changes.size} change#{changes.size == 1 ? '' : 's'}", "`#{@root}' has been pushed") elsif @current_revision.nil? # ignorable elsif out[/\[rejected\]/] warn("There was a conflict in #{@root}, retrying", '') else error("BAD Could not push changes in #{@root}", out) # TODO: need to add a status on shares so that the push problem can be # displayed. end end rescue # Rescue any standard exceptions which come from the push related # commands. This will prevent problems on a single share from killing # the entire daemon. error("Unexpected error pushing changes in #{@root}") # TODO: get logging and/or put the error message into a status field in the database end def get_latest_changes if @current_revision out = sh "git log #{@current_revision}.. --pretty='format:{\"commit\": \"%H\",%n \"author\": \"%an <%ae>\",%n \"date\": \"%ad\",%n \"message\": \"%s\"%n}'" if out.empty? [] else lines = [] Yajl::Parser.new.parse(out) do |obj| lines << obj end @current_revision = sh('git rev-parse HEAD').strip lines end else [] end end IGNORED_FILES = ['.gitignore'] # Returns the list of files in a given directory # dir_files("some/dir") => [, ] def dir_files(dir_path) Dir[File.join(dir_path, '*')].to_a.map { |path| Docfile.new(path) } end # Returns file meta data based on relative file path # file_meta("path/to/file") # => { :author => "Nick", :size => 1000, :modified => ... } def file_meta(file) file = file.gsub(%r{^/}, '') full_path = File.expand_path(file, @root) log_result = sh_string("git log --format='%aN|%ai' -n1 #{ShellTools.escape(file)}") author, modified = log_result.split('|') modified = Time.parse(modified.sub(' ', 'T')).utc.iso8601 size = if File.directory?(full_path) Dir[File.join(full_path, '**', '*')].reduce(0) do |size, file| File.symlink?(file) ? size : size += File.size(file) end else File.symlink?(full_path) ? 0 : File.size(full_path) end size = -1 if size == 0 # A value of 0 breaks the table sort for some reason { author: author, size: size, modified: modified } end # Returns the revisions available for a particular file # file_revisions("README") def file_revisions(file) file = file.gsub(%r{^/}, '') output = sh_string("git log --format='%h|%s|%aN|%ai' -n100 #{ShellTools.escape(file)}") output.to_s.split("\n").map do |log_result| commit, subject, author, date = log_result.split('|') date = Time.parse(date.sub(' ', 'T')).utc.iso8601 { commit: commit, subject: subject, author: author, date: date } end end # Returns the temporary path of a particular revision of a file # file_revision_at("README", "a4c56h") => "/tmp/some/path/README" def file_revision_at(file, ref) file = file.gsub(%r{^/}, '') content = sh_string("git show #{ref}:#{ShellTools.escape(file)}") tmp_path = File.expand_path(File.basename(file), Dir.tmpdir) File.open(tmp_path, 'w') { |f| f.puts content } tmp_path end # Revert a file to a particular revision def file_revert(file, ref) if file_revisions(file).map { |r| r[:commit] }.include? ref file = file.gsub(%r{^/}, '') full_path = File.expand_path(file, @root) content = File.read(file_revision_at(file, ref)) File.open(full_path, 'w') { |f| f.puts content } end end def valid? out, status = sh_with_code 'git status' @root.present? && status.success? end def warn(title, msg) if @show_notifications Guard::Notifier.notify(msg, title: title) else Kernel.warn("#{title}: #{msg}") end rescue # Prevent StandardErrors from stopping the daemon. end def info(title, msg) if @show_notifications Guard::Notifier.notify(msg, title: title, image: @icon) else puts("#{title}: #{msg}") end rescue # Prevent StandardErrors from stopping the daemon. end def error(title, msg) if @show_notifications Guard::Notifier.notify(msg, title: title, image: :failure) else Kernel.warn("#{title}: #{msg}") end rescue # Prevent StandardErrors from stopping the daemon. end # sh_string("git config branch.`git branch | grep '^\*' | sed -e 's/\* //'`.remote", "origin") def sh_string(cmd, default = nil) val = sh(cmd).strip rescue nil val.nil? || val.empty? ? default : val end # Run in shell, return both status and output # @see #sh def sh_with_code(cmd) ShellTools.sh_with_code(cmd, @root) end end end