module Gitrb class NotFound < StandardError; end class CommandError < StandardError attr_reader :command, :args, :output def initialize(command, args, output) super("#{command} failed") @command = command @args = args @output = output end end class Repository attr_reader :path, :root, :branch, :head, :encoding SHA_PATTERN = /^[A-Fa-f0-9]{5,40}$/ REVISION_PATTERN = /^[\w\-\.]+([\^~](\d+)?)*$/ DEFAULT_ENCODING = 'utf-8' MIN_GIT_VERSION = '1.6.0' LOCK = 'Gitrb.Repository.lock' if RUBY_VERSION > '1.9' def set_encoding(s); s.force_encoding(@encoding); end else def set_encoding(s); s; end end # Initialize a repository. def initialize(options = {}) @bare = options[:bare] || false @branch = options[:branch] || 'master' @logger = options[:logger] || Logger.new(nil) @encoding = options[:encoding] || DEFAULT_ENCODING @path = options[:path] @path.chomp!('/') @path += '/.git' if !@bare check_git_version if !options[:ignore_version] open_repository(options[:create]) load_packs load end # Bare repository? def bare? @bare end # Switch branch def branch=(branch) @branch = branch load end # Has our repository been changed on disk? def changed? head.nil? || head.id != read_head_id end # Load the repository, if it has been changed on disk. def refresh load if changed? end # Is there any transaction going on? def in_transaction? Thread.current[LOCK] end # Diff def diff(from, to, path = nil) if from && !(Commit === from) raise ArgumentError, "Invalid sha: #{from}" if from !~ SHA_PATTERN from = Reference.new(:repository => self, :id => from) end if !(Commit === to) raise ArgumentError, "Invalid sha: #{to}" if to !~ SHA_PATTERN to = Reference.new(:repository => self, :id => to) end Diff.new(from, to, git_diff_tree('--root', '-u', '--full-index', from && from.id, to.id, '--', path)) end # All changes made inside a transaction are atomic. If some # exception occurs the transaction will be rolled back. # # Example: # repository.transaction { repository['a'] = 'b' } # def transaction(message = '', author = nil, committer = nil) start_transaction result = yield commit(message, author, committer) result rescue rollback_transaction raise ensure finish_transaction end # Write a commit object to disk and set the head of the current branch. # # Returns the commit object def commit(message = '', author = nil, committer = nil) return if !root.modified? author ||= default_user committer ||= author root.save commit = Commit.new(:repository => self, :tree => root, :parents => head, :author => author, :committer => committer, :message => message) commit.save write_head_id(commit.id) load commit end # Returns a list of commits starting from head commit. def log(limit = 10, start = nil, path = nil) limit = limit.to_s start = start.to_s raise ArgumentError, "Invalid limit: #{limit}" if limit !~ /^\d+$/ raise ArgumentError, "Invalid commit: #{start}" if start =~ /^\-/ args = ['--pretty=tformat:%H%n%P%n%T%n%an%n%ae%n%at%n%cn%n%ce%n%ct%n%x00%s%n%b%x00', "-#{limit}" ] args << start if !start.empty? args << '--' << path if path && !path.empty? log = git_log(*args).split(/\n*\x00\n*/) commits = [] log.each_slice(2) do |data, message| data = data.split("\n") parents = data[1].empty? ? nil : data[1].split(' ').map {|id| Reference.new(:repository => self, :id => id) } commits << Commit.new(:repository => self, :id => data[0], :parents => parents, :tree => Reference.new(:repository => self, :id => data[2]), :author => User.new(data[3], data[4], Time.at(data[5].to_i)), :committer => User.new(data[6], data[7], Time.at(data[8].to_i)), :message => message.strip) end commits rescue => ex return [] if ex.message =~ /bad default revision 'HEAD'/i raise end # Get an object by its id. # # Returns a tree, blob, commit or tag object. def get(id) raise ArgumentError, 'No id given' if !(String === id) if id =~ SHA_PATTERN raise NotFound, "Sha too short: #{id}" if id.length < 5 list = @objects.find(id).to_a raise NotFound, "Sha is ambiguous: #{id}" if list.size > 1 return list.first if list.size == 1 elsif id =~ REVISION_PATTERN list = git_rev_parse(id).split("\n") rescue nil raise NotFound, "Revision not found: #{id}" if !list || list.empty? raise NotFound, "Revision is ambiguous: #{id}" if list.size > 1 id = list.first end @logger.debug "gitrb: Loading #{id}" path = object_path(id) if File.exists?(path) || (glob = Dir.glob(path + '*')).size >= 1 if glob raise NotFound, "Sha is ambiguous: #{id}" if glob.size > 1 path = glob.first id = path[-41..-40] + path[-38..-1] end buf = File.open(path, 'rb') { |f| f.read } raise NotFound, "Not a loose object: #{id}" if !legacy_loose_object?(buf) header, content = Zlib::Inflate.inflate(buf).split("\0", 2) type, size = header.split(' ', 2) raise NotFound, "Bad object: #{id}" if content.length != size.to_i else trie = @packs.find(id) raise NotFound, "Object not found: #{id}" if !trie id += trie.key[-(41 - id.length)...-1] list = trie.to_a raise NotFound, "Sha is ambiguous: #{id}" if list.size > 1 pack, offset = list.first content, type = pack.get_object(offset) end @logger.debug "gitrb: Loaded #{id}" set_encoding(id) object = GitObject.factory(type, :repository => self, :id => id, :data => content) @objects.insert(id, object) object end def get_tree(id) get_type(id, :tree) end def get_blob(id) get_type(id, :blob) end def get_commit(id) get_type(id, :commit) end # Write a raw object to the repository. # # Returns the object. def put(object) content = object.dump data = "#{object.type} #{content.bytesize rescue content.length}\0#{content}" id = sha(data) path = object_path(id) @logger.debug "gitrb: Storing #{id}" if !File.exists?(path) FileUtils.mkpath(File.dirname(path)) File.open(path, 'wb') do |f| f.write Zlib::Deflate.deflate(data) end end @logger.debug "gitrb: Stored #{id}" set_encoding(id) object.repository = self object.id = id @objects.insert(id, object) object end def method_missing(name, *args, &block) cmd = name.to_s if cmd[0..3] == 'git_' ENV['GIT_DIR'] = path cmd = cmd[4..-1].tr('_', '-') args = args.flatten.compact.map {|s| "'" + s.to_s.gsub("'", "'\\\\''") + "'" }.join(' ') cmdline = "git #{cmd} #{args} 2>&1" @logger.debug "gitrb: #{cmdline}" # Read in binary mode (ascii-8bit) and convert afterwards out = if block_given? IO.popen(cmdline, 'rb', &block) else set_encoding IO.popen(cmdline, 'rb') {|io| io.read } end if $?.exitstatus > 0 return '' if $?.exitstatus == 1 && out == '' raise CommandError.new("git #{cmd}", args, out) end out else super end end def default_user name = git_config('user.name').chomp email = git_config('user.email').chomp if name.empty? require 'etc' user = Etc.getpwnam(Etc.getlogin) name = user.gecos end if email.empty? require 'etc' email = Etc.getlogin + '@' + `hostname -f`.chomp end User.new(name, email) end def dup super.instance_eval do @objects = Trie.new load self end end private def check_git_version version = git_version raise "Invalid git version: #{version}" if version !~ /^git version ([\d\.]+)$/ a = $1.split('.').map {|s| s.to_i } b = MIN_GIT_VERSION.split('.').map {|s| s.to_i } while !a.empty? && !b.empty? && a.first == b.first a.shift b.shift end raise "Minimum required git version is #{MIN_GIT_VERSION}" if a.first.to_i < b.first.to_i end def open_repository(create) if create && !File.exists?("#{@path}/objects") FileUtils.mkpath(@path) if !File.exists?(@path) raise ArgumentError, "Not a valid Git repository: '#{@path}'" if !File.directory?(@path) if @bare Dir.chdir(@path) { git_init '--bare' } else Dir.chdir(@path[0..-6]) { git_init } end else raise ArgumentError, "Not a valid Git repository: '#{@path}'" if !File.directory?("#{@path}/objects") end end # Start a transaction. # # Tries to get lock on lock file, load the this repository if # has changed in the repository. def start_transaction file = File.open("#{head_path}.lock", 'w') file.flock(File::LOCK_EX) Thread.current[LOCK] = file refresh end # Rerepository the state of the repository. # # Any changes made to the repository are discarded. def rollback_transaction @objects.clear load finish_transaction end # Finish the transaction. # # Release the lock file. def finish_transaction Thread.current[LOCK].close rescue nil Thread.current[LOCK] = nil File.unlink("#{head_path}.lock") rescue nil end def get_type(id, expected) object = get(id) raise NotFound, "Wrong type #{object.type}, expected #{expected}" if object && object.type != expected object end def load_packs @packs = Trie.new @objects = Trie.new packs_path = "#{@path}/objects/pack" if File.directory?(packs_path) Dir.open(packs_path) do |dir| entries = dir.select { |entry| entry =~ /\.pack$/i } entries.each do |entry| @logger.debug "gitrb: Loading pack #{entry}" pack = Pack.new(File.join(packs_path, entry)) pack.each_object {|id, offset| @packs.insert(id, [pack, offset]) } end end end end def load if id = read_head_id @head = get_commit(id) @root = @head.tree else @head = nil @root = Tree.new(:repository => self) end @logger.debug "gitrb: Reloaded, head is #{@head ? head.id : 'nil'}" end # Returns the hash value of an object string. def sha(str) Digest::SHA1.hexdigest(str)[0, 40] end # Returns the path to the current head file. def head_path "#{path}/refs/heads/#{branch}" end # Returns the path to the object file for given id. def object_path(id) "#{path}/objects/#{id[0...2]}/#{id[2..39]}" end # Read the id of the head commit. # # Returns the object id of the last commit. def read_head_id if File.exists?(head_path) File.read(head_path).strip elsif File.exists?("#{path}/packed-refs") File.open("#{path}/packed-refs", 'rb') do |io| while line = io.gets line.strip! next if line[0..0] == '#' line = line.split(' ') return line[0] if line[1] == "refs/heads/#{branch}" end end end end def write_head_id(id) File.open(head_path, 'wb') {|file| file.write(id) } end def legacy_loose_object?(buf) buf[0].ord == 0x78 && ((buf[0].ord << 8) | buf[1].ord) % 31 == 0 end end end