├── lib ├── database │ ├── entry.rb │ ├── blob.rb │ ├── author.rb │ ├── backends.rb │ ├── commit.rb │ ├── tree_diff.rb │ ├── tree.rb │ ├── loose.rb │ └── packed.rb ├── sorted_hash.rb ├── repository │ ├── divergence.rb │ ├── hard_reset.rb │ ├── inspector.rb │ ├── pending_commit.rb │ ├── status.rb │ ├── sequencer.rb │ └── migration.rb ├── command │ ├── shared │ │ ├── fast_forward.rb │ │ ├── send_objects.rb │ │ ├── receive_objects.rb │ │ ├── remote_agent.rb │ │ ├── remote_client.rb │ │ ├── print_diff.rb │ │ ├── sequencing.rb │ │ └── write_commit.rb │ ├── rev_list.rb │ ├── init.rb │ ├── upload_pack.rb │ ├── remote.rb │ ├── cherry_pick.rb │ ├── reset.rb │ ├── add.rb │ ├── revert.rb │ ├── base.rb │ ├── commit.rb │ ├── receive_pack.rb │ ├── checkout.rb │ ├── diff.rb │ ├── rm.rb │ ├── fetch.rb │ ├── config.rb │ ├── merge.rb │ ├── log.rb │ └── push.rb ├── pager.rb ├── color.rb ├── temp_file.rb ├── index │ ├── checksum.rb │ └── entry.rb ├── merge │ ├── bases.rb │ ├── inputs.rb │ ├── common_ancestors.rb │ └── diff3.rb ├── config │ └── stack.rb ├── pack │ ├── window.rb │ ├── entry.rb │ ├── expander.rb │ ├── delta.rb │ ├── unpacker.rb │ ├── stream.rb │ ├── numbers.rb │ ├── compressor.rb │ ├── reader.rb │ ├── index.rb │ ├── writer.rb │ └── xdelta.rb ├── pack.rb ├── diff.rb ├── path_filter.rb ├── editor.rb ├── lockfile.rb ├── remotes │ ├── remote.rb │ ├── protocol.rb │ └── refspec.rb ├── repository.rb ├── diff │ ├── hunk.rb │ ├── combined.rb │ └── myers.rb ├── progress.rb ├── command.rb ├── remotes.rb ├── database.rb ├── workspace.rb └── revision.rb ├── Rakefile ├── test ├── remote_repo.rb ├── graph_helper.rb ├── merge │ └── diff3_test.rb ├── index_test.rb ├── diff_test.rb ├── command │ ├── remote_test.rb │ ├── add_test.rb │ ├── config_test.rb │ ├── diff_test.rb │ ├── commit_test.rb │ └── status_test.rb ├── pack │ ├── delta_test.rb │ └── xdelta_test.rb ├── revision_test.rb ├── database │ └── tree_diff_test.rb └── command_helper.rb └── bin └── jit /lib/database/entry.rb: -------------------------------------------------------------------------------- 1 | class Database 2 | Entry = Struct.new(:oid, :mode) do 3 | def tree? 4 | mode == Tree::TREE_MODE 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rake/testtask" 2 | 3 | Rake::TestTask.new do |task| 4 | task.libs << "test" 5 | task.pattern = "test/**/*_test.rb" 6 | end 7 | 8 | task :default => :test 9 | -------------------------------------------------------------------------------- /test/remote_repo.rb: -------------------------------------------------------------------------------- 1 | require "command_helper" 2 | 3 | class RemoteRepo 4 | include CommandHelper 5 | 6 | def initialize(name) 7 | @name = name 8 | end 9 | 10 | def repo_path 11 | Pathname.new(File.expand_path("../test-repo-#{ @name }", __FILE__)) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/sorted_hash.rb: -------------------------------------------------------------------------------- 1 | require "set" 2 | 3 | class SortedHash < Hash 4 | def initialize 5 | super 6 | @keys = SortedSet.new 7 | end 8 | 9 | def []=(key, value) 10 | @keys.add(key) 11 | super 12 | end 13 | 14 | def each 15 | @keys.each { |key| yield [key, self[key]] } 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/database/blob.rb: -------------------------------------------------------------------------------- 1 | class Database 2 | class Blob 3 | 4 | attr_accessor :oid 5 | attr_reader :data 6 | 7 | def self.parse(scanner) 8 | Blob.new(scanner.rest) 9 | end 10 | 11 | def initialize(data) 12 | @data = data 13 | end 14 | 15 | def type 16 | "blob" 17 | end 18 | 19 | def to_s 20 | @data 21 | end 22 | 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /bin/jit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../lib/command" 4 | 5 | begin 6 | cmd = Command.execute(Dir.getwd, ENV, ARGV, $stdin, $stdout, $stderr) 7 | exit cmd.status 8 | 9 | rescue Command::Unknown => error 10 | $stderr.puts "jit: #{ error.message }" 11 | exit 1 12 | 13 | rescue => error 14 | $stderr.puts "fatal: #{ error.message }" 15 | if ENV["DEBUG"] 16 | error.backtrace.each do |line| 17 | $stderr.puts " from #{ line }" 18 | end 19 | end 20 | exit 1 21 | end 22 | -------------------------------------------------------------------------------- /lib/repository/divergence.rb: -------------------------------------------------------------------------------- 1 | require_relative "../merge/common_ancestors" 2 | 3 | class Repository 4 | class Divergence 5 | 6 | attr_reader :upstream, :ahead, :behind 7 | 8 | def initialize(repo, ref) 9 | @upstream = repo.remotes.get_upstream(ref.short_name) 10 | return unless @upstream 11 | 12 | left = ref.read_oid 13 | right = repo.refs.read_ref(@upstream) 14 | common = Merge::CommonAncestors.new(repo.database, left, [right]) 15 | 16 | common.find 17 | @ahead, @behind = common.counts 18 | end 19 | 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/command/shared/fast_forward.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../merge/common_ancestors" 2 | 3 | module Command 4 | module FastForward 5 | 6 | def fast_forward_error(old_oid, new_oid) 7 | return nil unless old_oid and new_oid 8 | return "fetch first" unless repo.database.has?(old_oid) 9 | return "non-fast-forward" unless fast_forward?(old_oid, new_oid) 10 | end 11 | 12 | def fast_forward?(old_oid, new_oid) 13 | common = ::Merge::CommonAncestors.new(repo.database, old_oid, [new_oid]) 14 | common.find 15 | common.marked?(old_oid, :parent2) 16 | end 17 | 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/pager.rb: -------------------------------------------------------------------------------- 1 | class Pager 2 | PAGER_CMD = "less" 3 | PAGER_ENV = { "LESS" => "FRX", "LV" => "-c" } 4 | 5 | attr_reader :input 6 | 7 | def initialize(env = {}, stdout = $stdout, stderr = $stderr) 8 | env = PAGER_ENV.merge(env) 9 | cmd = env["GIT_PAGER"] || env["PAGER"] || PAGER_CMD 10 | 11 | reader, writer = IO.pipe 12 | options = { :in => reader, :out => stdout, :err => stderr } 13 | 14 | @pid = Process.spawn(env, cmd, options) 15 | @input = writer 16 | 17 | reader.close 18 | end 19 | 20 | def wait 21 | Process.waitpid(@pid) if @pid 22 | @pid = nil 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/database/author.rb: -------------------------------------------------------------------------------- 1 | require "time" 2 | 3 | class Database 4 | TIME_FORMAT = "%s %z" 5 | 6 | Author = Struct.new(:name, :email, :time) do 7 | def self.parse(string) 8 | name, email, time = string.split(/<|>/).map(&:strip) 9 | time = Time.strptime(time, TIME_FORMAT) 10 | 11 | Author.new(name, email, time) 12 | end 13 | 14 | def short_date 15 | time.strftime("%Y-%m-%d") 16 | end 17 | 18 | def readable_time 19 | time.strftime("%a %b %-d %H:%M:%S %Y %z") 20 | end 21 | 22 | def to_s 23 | timestamp = time.strftime(TIME_FORMAT) 24 | "#{ name } <#{ email }> #{ timestamp }" 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/color.rb: -------------------------------------------------------------------------------- 1 | module Color 2 | SGR_CODES = { 3 | "normal" => 0, 4 | "bold" => 1, 5 | "dim" => 2, 6 | "italic" => 3, 7 | "ul" => 4, 8 | "reverse" => 7, 9 | "strike" => 9, 10 | "black" => 30, 11 | "red" => 31, 12 | "green" => 32, 13 | "yellow" => 33, 14 | "blue" => 34, 15 | "magenta" => 35, 16 | "cyan" => 36, 17 | "white" => 37 18 | } 19 | 20 | def self.format(style, string) 21 | codes = [*style].map { |name| SGR_CODES.fetch(name.to_s) } 22 | color = false 23 | 24 | codes.each_with_index do |code, i| 25 | next unless code >= 30 26 | codes[i] += 10 if color 27 | color = true 28 | end 29 | 30 | "\e[#{ codes.join(";") }m#{ string }\e[m" 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/temp_file.rb: -------------------------------------------------------------------------------- 1 | class TempFile 2 | TEMP_CHARS = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a 3 | 4 | def initialize(dirname, prefix) 5 | @dirname = dirname 6 | @path = @dirname.join(generate_temp_name(prefix)) 7 | @file = nil 8 | end 9 | 10 | def write(data) 11 | open_file unless @file 12 | @file.write(data) 13 | end 14 | 15 | def move(name) 16 | @file.close 17 | File.rename(@path, @dirname.join(name)) 18 | end 19 | 20 | private 21 | 22 | def generate_temp_name(prefix) 23 | id = (1..6).map { TEMP_CHARS.sample }.join("") 24 | "#{ prefix }_#{ id }" 25 | end 26 | 27 | def open_file 28 | flags = File::RDWR | File::CREAT | File::EXCL 29 | @file = File.open(@path, flags) 30 | rescue Errno::ENOENT 31 | Dir.mkdir(@dirname) 32 | retry 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/command/shared/send_objects.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../pack" 2 | require_relative "../../progress" 3 | require_relative "../../rev_list" 4 | 5 | module Command 6 | module SendObjects 7 | 8 | def send_packed_objects(revs) 9 | rev_opts = { :objects => true, :missing => true } 10 | rev_list = ::RevList.new(repo, revs, rev_opts) 11 | 12 | pack_compression = repo.config.get(["pack", "compression"]) || 13 | repo.config.get(["core", "compression"]) 14 | 15 | writer = Pack::Writer.new(@conn.output, repo.database, 16 | :compression => pack_compression, 17 | :allow_ofs => @conn.capable?("ofs-delta"), 18 | :progress => Progress.new(@stderr)) 19 | 20 | writer.write_objects(rev_list) 21 | end 22 | 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/repository/hard_reset.rb: -------------------------------------------------------------------------------- 1 | require "pathname" 2 | 3 | class Repository 4 | class HardReset 5 | 6 | def initialize(repo, oid) 7 | @repo = repo 8 | @oid = oid 9 | end 10 | 11 | def execute 12 | @status = @repo.status(@oid) 13 | changed = @status.changed.map { |path| Pathname.new(path) } 14 | 15 | changed.each { |path| reset_path(path) } 16 | end 17 | 18 | private 19 | 20 | def reset_path(path) 21 | @repo.index.remove(path) 22 | @repo.workspace.remove(path) 23 | 24 | entry = @status.head_tree[path.to_s] 25 | return unless entry 26 | 27 | blob = @repo.database.load(entry.oid) 28 | @repo.workspace.write_file(path, blob.data, entry.mode, true) 29 | 30 | stat = @repo.workspace.stat_file(path) 31 | @repo.index.add(path, entry.oid, stat) 32 | end 33 | 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/index/checksum.rb: -------------------------------------------------------------------------------- 1 | require "digest/sha1" 2 | 3 | class Index 4 | class Checksum 5 | 6 | EndOfFile = Class.new(StandardError) 7 | 8 | CHECKSUM_SIZE = 20 9 | 10 | def initialize(file) 11 | @file = file 12 | @digest = Digest::SHA1.new 13 | end 14 | 15 | def write(data) 16 | @file.write(data) 17 | @digest.update(data) 18 | end 19 | 20 | def write_checksum 21 | @file.write(@digest.digest) 22 | end 23 | 24 | def read(size) 25 | data = @file.read(size) 26 | 27 | unless data.bytesize == size 28 | raise EndOfFile, "Unexpected end-of-file while reading index" 29 | end 30 | 31 | @digest.update(data) 32 | data 33 | end 34 | 35 | def verify_checksum 36 | sum = @file.read(CHECKSUM_SIZE) 37 | 38 | unless sum == @digest.digest 39 | raise Invalid, "Checksum does not match value stored on disk" 40 | end 41 | end 42 | 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/graph_helper.rb: -------------------------------------------------------------------------------- 1 | require "fileutils" 2 | require "pathname" 3 | 4 | require "database" 5 | 6 | module GraphHelper 7 | def self.included(suite) 8 | suite.before { FileUtils.mkdir_p(db_path) } 9 | suite.after { FileUtils.rm_rf(db_path) } 10 | end 11 | 12 | def db_path 13 | Pathname.new(File.expand_path("../test-database", __FILE__)) 14 | end 15 | 16 | def database 17 | @database ||= Database.new(db_path) 18 | end 19 | 20 | def commit(parents, message) 21 | @commits ||= {} 22 | @time ||= Time.now 23 | 24 | parents = parents.map { |oid| @commits[oid] } 25 | author = Database::Author.new("A. U. Thor", "author@example.com", @time) 26 | commit = Database::Commit.new(parents, "0" * 40, author, author, message) 27 | 28 | database.store(commit) 29 | @commits[message] = commit.oid 30 | end 31 | 32 | def chain(names) 33 | names.each_cons(2) { |parent, message| commit([*parent], message) } 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/merge/bases.rb: -------------------------------------------------------------------------------- 1 | require "set" 2 | require_relative "./common_ancestors" 3 | 4 | module Merge 5 | class Bases 6 | 7 | def initialize(database, one, two) 8 | @database = database 9 | @common = CommonAncestors.new(@database, one, [two]) 10 | end 11 | 12 | def find 13 | @commits = @common.find 14 | return @commits if @commits.size <= 1 15 | 16 | @redundant = Set.new 17 | @commits.each { |commit| filter_commit(commit) } 18 | @commits - @redundant.to_a 19 | end 20 | 21 | private 22 | 23 | def filter_commit(commit) 24 | return if @redundant.include?(commit) 25 | 26 | others = @commits - [commit, *@redundant] 27 | common = CommonAncestors.new(@database, commit, others) 28 | 29 | common.find 30 | 31 | @redundant.add(commit) if common.marked?(commit, :parent2) 32 | 33 | others.select! { |oid| common.marked?(oid, :parent1) } 34 | @redundant.merge(others) 35 | end 36 | 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/config/stack.rb: -------------------------------------------------------------------------------- 1 | require "pathname" 2 | require_relative "../config" 3 | 4 | class Config 5 | class Stack 6 | 7 | GLOBAL_CONFIG = File.expand_path("~/.gitconfig") 8 | SYSTEM_CONFIG = "/etc/gitconfig" 9 | 10 | def initialize(git_path) 11 | @configs = { 12 | :local => Config.new(git_path.join("config")), 13 | :global => Config.new(Pathname.new(GLOBAL_CONFIG)), 14 | :system => Config.new(Pathname.new(SYSTEM_CONFIG)) 15 | } 16 | end 17 | 18 | def file(name) 19 | if @configs.has_key?(name) 20 | @configs[name] 21 | else 22 | Config.new(Pathname.new(name)) 23 | end 24 | end 25 | 26 | def open 27 | @configs.each_value(&:open) 28 | end 29 | 30 | def get(key) 31 | get_all(key).last 32 | end 33 | 34 | def get_all(key) 35 | [:system, :global, :local].flat_map do |name| 36 | @configs[name].open 37 | @configs[name].get_all(key) 38 | end 39 | end 40 | 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/pack/window.rb: -------------------------------------------------------------------------------- 1 | require "forwardable" 2 | 3 | module Pack 4 | class Window 5 | 6 | Unpacked = Struct.new(:entry, :data) do 7 | extend Forwardable 8 | def_delegators :entry, :type, :size, :delta, :depth 9 | 10 | attr_accessor :delta_index 11 | end 12 | 13 | def initialize(size) 14 | @objects = Array.new(size) 15 | @offset = 0 16 | end 17 | 18 | def add(entry, data) 19 | unpacked = Unpacked.new(entry, data) 20 | @objects[@offset] = unpacked 21 | @offset = wrap(@offset + 1) 22 | 23 | unpacked 24 | end 25 | 26 | def each 27 | cursor = wrap(@offset - 2) 28 | limit = wrap(@offset - 1) 29 | 30 | loop do 31 | break if cursor == limit 32 | 33 | unpacked = @objects[cursor] 34 | yield unpacked if unpacked 35 | 36 | cursor = wrap(cursor - 1) 37 | end 38 | end 39 | 40 | private 41 | 42 | def wrap(offset) 43 | offset % @objects.size 44 | end 45 | 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/merge/inputs.rb: -------------------------------------------------------------------------------- 1 | require_relative "./bases" 2 | require_relative "../revision" 3 | 4 | module Merge 5 | class Inputs 6 | 7 | ATTRS = [ :left_name, :right_name, 8 | :left_oid, :right_oid, 9 | :base_oids ] 10 | 11 | attr_reader(*ATTRS) 12 | 13 | def initialize(repository, left_name, right_name) 14 | @repo = repository 15 | @left_name = left_name 16 | @right_name = right_name 17 | 18 | @left_oid = resolve_rev(@left_name) 19 | @right_oid = resolve_rev(@right_name) 20 | 21 | common = Bases.new(@repo.database, @left_oid, @right_oid) 22 | @base_oids = common.find 23 | end 24 | 25 | def already_merged? 26 | @base_oids == [@right_oid] 27 | end 28 | 29 | def fast_forward? 30 | @base_oids == [@left_oid] 31 | end 32 | 33 | private 34 | 35 | def resolve_rev(rev) 36 | Revision.new(@repo, rev).resolve(Revision::COMMIT) 37 | end 38 | 39 | end 40 | 41 | CherryPick = Struct.new(*Inputs::ATTRS) 42 | end 43 | -------------------------------------------------------------------------------- /lib/pack.rb: -------------------------------------------------------------------------------- 1 | require_relative "./pack/reader" 2 | require_relative "./pack/index" 3 | require_relative "./pack/writer" 4 | require_relative "./pack/stream" 5 | require_relative "./pack/indexer" 6 | require_relative "./pack/unpacker" 7 | 8 | module Pack 9 | HEADER_SIZE = 12 10 | HEADER_FORMAT = "a4N2" 11 | SIGNATURE = "PACK" 12 | VERSION = 2 13 | 14 | GIT_MAX_COPY = 0x10000 15 | MAX_COPY_SIZE = 0xffffff 16 | MAX_INSERT_SIZE = 0x7f 17 | 18 | IDX_SIGNATURE = 0xff744f63 19 | IDX_MAX_OFFSET = 0x80000000 20 | 21 | COMMIT = 1 22 | TREE = 2 23 | BLOB = 3 24 | 25 | OFS_DELTA = 6 26 | REF_DELTA = 7 27 | 28 | TYPE_CODES = { 29 | "commit" => COMMIT, 30 | "tree" => TREE, 31 | "blob" => BLOB 32 | } 33 | 34 | InvalidPack = Class.new(StandardError) 35 | 36 | Record = Struct.new(:type, :data) do 37 | attr_accessor :oid 38 | 39 | def to_s 40 | data 41 | end 42 | end 43 | 44 | OfsDelta = Struct.new(:base_ofs, :delta_data) 45 | RefDelta = Struct.new(:base_oid, :delta_data) 46 | end 47 | -------------------------------------------------------------------------------- /lib/command/rev_list.rb: -------------------------------------------------------------------------------- 1 | require_relative "./base" 2 | require_relative "../rev_list" 3 | 4 | module Command 5 | class RevList < Base 6 | 7 | def define_options 8 | @parser.on("--all") { @options[:all] = true } 9 | @parser.on("--branches") { @options[:branches] = true } 10 | @parser.on("--remotes") { @options[:remotes] = true } 11 | 12 | @parser.on("--ignore-missing") { @options[:missing] = true } 13 | @parser.on("--objects") { @options[:objects] = true } 14 | @parser.on("--reverse") { @options[:reverse] = true } 15 | 16 | @options[:walk] = true 17 | @parser.on("--do-walk") { @options[:walk] = true } 18 | @parser.on("--no-walk") { @options[:walk] = false } 19 | end 20 | 21 | def run 22 | rev_list = ::RevList.new(repo, @args, @options) 23 | iterator = @options[:reverse] ? :reverse_each : :each 24 | 25 | rev_list.__send__(iterator) do |object, path| 26 | puts "#{ object.oid } #{ path }".strip 27 | end 28 | 29 | exit 0 30 | end 31 | 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/diff.rb: -------------------------------------------------------------------------------- 1 | require_relative "./diff/combined" 2 | require_relative "./diff/hunk" 3 | require_relative "./diff/myers" 4 | 5 | module Diff 6 | SYMBOLS = { 7 | :eql => " ", 8 | :ins => "+", 9 | :del => "-" 10 | } 11 | 12 | Line = Struct.new(:number, :text) 13 | 14 | Edit = Struct.new(:type, :a_line, :b_line) do 15 | def a_lines 16 | [a_line] 17 | end 18 | 19 | def to_s 20 | line = a_line || b_line 21 | SYMBOLS.fetch(type) + line.text 22 | end 23 | end 24 | 25 | def self.lines(document) 26 | document = document.lines if document.is_a?(String) 27 | document.map.with_index { |text, i| Line.new(i + 1, text) } 28 | end 29 | 30 | def self.diff(a, b) 31 | Myers.diff(Diff.lines(a), Diff.lines(b)) 32 | end 33 | 34 | def self.diff_hunks(a, b) 35 | Hunk.filter(Diff.diff(a, b)) 36 | end 37 | 38 | def self.combined(as, b) 39 | diffs = as.map { |a| Diff.diff(a, b) } 40 | Combined.new(diffs).to_a 41 | end 42 | 43 | def self.combined_hunks(as, b) 44 | Hunk.filter(Diff.combined(as, b)) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/command/init.rb: -------------------------------------------------------------------------------- 1 | require "fileutils" 2 | 3 | require_relative "./base" 4 | require_relative "../config" 5 | require_relative "../refs" 6 | 7 | module Command 8 | class Init < Base 9 | 10 | DEFAULT_BRANCH = "master" 11 | 12 | def run 13 | path = @args.fetch(0, @dir) 14 | 15 | root_path = expanded_pathname(path) 16 | git_path = root_path.join(".git") 17 | 18 | ["objects", "refs/heads"].each do |dir| 19 | begin 20 | FileUtils.mkdir_p(git_path.join(dir)) 21 | rescue Errno::EACCES => error 22 | @stderr.puts "fatal: #{ error.message }" 23 | exit 1 24 | end 25 | end 26 | 27 | config = ::Config.new(git_path.join("config")) 28 | config.open_for_update 29 | config.set(["core", "bare"], false) 30 | config.save 31 | 32 | refs = Refs.new(git_path) 33 | path = File.join("refs", "heads", DEFAULT_BRANCH) 34 | refs.update_head("ref: #{ path }") 35 | 36 | puts "Initialized empty Jit repository in #{ git_path }" 37 | exit 0 38 | end 39 | 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/command/shared/receive_objects.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../pack" 2 | require_relative "../../progress" 3 | 4 | module Command 5 | module ReceiveObjects 6 | 7 | UNPACK_LIMIT = 100 8 | 9 | def recv_packed_objects(unpack_limit = nil, prefix = "") 10 | stream = Pack::Stream.new(@conn.input, prefix) 11 | reader = Pack::Reader.new(stream) 12 | progress = Progress.new(@stderr) unless @conn.input == STDIN 13 | 14 | reader.read_header 15 | 16 | factory = select_processor_class(reader, unpack_limit) 17 | processor = factory.new(repo.database, reader, stream, progress) 18 | 19 | processor.process_pack 20 | repo.database.reload 21 | end 22 | 23 | def select_processor_class(reader, unpack_limit) 24 | unpack_limit ||= transfer_unpack_limit 25 | 26 | if unpack_limit and reader.count > unpack_limit 27 | Pack::Indexer 28 | else 29 | Pack::Unpacker 30 | end 31 | end 32 | 33 | def transfer_unpack_limit 34 | repo.config.get(["transfer", "unpackLimit"]) || UNPACK_LIMIT 35 | end 36 | 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/path_filter.rb: -------------------------------------------------------------------------------- 1 | require "pathname" 2 | 3 | class PathFilter 4 | Trie = Struct.new(:matched, :children) do 5 | def self.from_paths(paths) 6 | root = Trie.node 7 | root.matched = true if paths.empty? 8 | 9 | paths.each do |path| 10 | trie = root 11 | path.each_filename { |name| trie = trie.children[name] } 12 | trie.matched = true 13 | end 14 | 15 | root 16 | end 17 | 18 | def self.node 19 | Trie.new(false, Hash.new { |hash, key| hash[key] = Trie.node }) 20 | end 21 | end 22 | 23 | attr_reader :path 24 | 25 | def self.build(paths) 26 | PathFilter.new(Trie.from_paths(paths)) 27 | end 28 | 29 | def initialize(routes = Trie.new(true), path = Pathname.new("")) 30 | @routes = routes 31 | @path = path 32 | end 33 | 34 | def each_entry(entries) 35 | entries.each do |name, entry| 36 | yield name, entry if @routes.matched or @routes.children.has_key?(name) 37 | end 38 | end 39 | 40 | def join(name) 41 | next_routes = @routes.matched ? @routes : @routes.children[name] 42 | PathFilter.new(next_routes, @path.join(name)) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/pack/entry.rb: -------------------------------------------------------------------------------- 1 | require "forwardable" 2 | require_relative "./numbers" 3 | 4 | module Pack 5 | class Entry 6 | 7 | extend Forwardable 8 | def_delegators :@info, :type, :size 9 | 10 | attr_accessor :offset 11 | attr_reader :oid, :delta, :depth 12 | 13 | def initialize(oid, info, path, ofs = false) 14 | @oid = oid 15 | @info = info 16 | @path = path 17 | @ofs = ofs 18 | @delta = nil 19 | @depth = 0 20 | end 21 | 22 | def sort_key 23 | [packed_type, @path&.basename, @path&.dirname, @info.size] 24 | end 25 | 26 | def assign_delta(delta) 27 | @delta = delta 28 | @depth = delta.base.depth + 1 29 | end 30 | 31 | def packed_type 32 | if @delta 33 | @ofs ? OFS_DELTA : REF_DELTA 34 | else 35 | TYPE_CODES.fetch(@info.type) 36 | end 37 | end 38 | 39 | def packed_size 40 | @delta ? @delta.size : @info.size 41 | end 42 | 43 | def delta_prefix 44 | return "" unless @delta 45 | 46 | if @ofs 47 | Numbers::VarIntBE.write(offset - @delta.base.offset) 48 | else 49 | [@delta.base.oid].pack("H40") 50 | end 51 | end 52 | 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/command/upload_pack.rb: -------------------------------------------------------------------------------- 1 | require "set" 2 | 3 | require_relative "./base" 4 | require_relative "./shared/remote_agent" 5 | require_relative "./shared/send_objects" 6 | 7 | module Command 8 | class UploadPack < Base 9 | 10 | include RemoteAgent 11 | include SendObjects 12 | 13 | CAPABILITIES = ["ofs-delta"] 14 | 15 | def run 16 | accept_client("upload-pack", CAPABILITIES) 17 | 18 | send_references 19 | recv_want_list 20 | recv_have_list 21 | send_objects 22 | 23 | exit 0 24 | end 25 | 26 | private 27 | 28 | def recv_want_list 29 | @wanted = recv_oids("want", nil) 30 | exit 0 if @wanted.empty? 31 | end 32 | 33 | def recv_have_list 34 | @remote_has = recv_oids("have", "done") 35 | @conn.send_packet("NAK") 36 | end 37 | 38 | def recv_oids(prefix, terminator) 39 | pattern = /^#{ prefix } ([0-9a-f]+)$/ 40 | result = Set.new 41 | 42 | @conn.recv_until(terminator) do |line| 43 | result.add(pattern.match(line)[1]) 44 | end 45 | result 46 | end 47 | 48 | def send_objects 49 | revs = @wanted + @remote_has.map { |oid| "^#{ oid }" } 50 | send_packed_objects(revs) 51 | end 52 | 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/command/shared/remote_agent.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../repository" 2 | require_relative "../../remotes/protocol" 3 | 4 | module Command 5 | module RemoteAgent 6 | 7 | ZERO_OID = "0" * 40 8 | 9 | def accept_client(name, capabilities = []) 10 | @conn = Remotes::Protocol.new(name, @stdin, @stdout, capabilities) 11 | end 12 | 13 | def repo 14 | @repo ||= Repository.new(detect_git_dir) 15 | end 16 | 17 | def detect_git_dir 18 | pathname = expanded_pathname(@args[0]) 19 | dirs = pathname.ascend.flat_map { |dir| [dir, dir.join(".git")] } 20 | dirs.find { |dir| git_repository?(dir) } 21 | end 22 | 23 | def git_repository?(dirname) 24 | File.file?(dirname.join("HEAD")) and 25 | File.directory?(dirname.join("objects")) and 26 | File.directory?(dirname.join("refs")) 27 | end 28 | 29 | def send_references 30 | refs = repo.refs.list_all_refs 31 | sent = false 32 | 33 | refs.sort_by(&:path).each do |symref| 34 | next unless oid = symref.read_oid 35 | @conn.send_packet("#{ oid.downcase } #{ symref.path }") 36 | sent = true 37 | end 38 | 39 | @conn.send_packet("#{ ZERO_OID } capabilities^{}") unless sent 40 | @conn.send_packet(nil) 41 | end 42 | 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/pack/expander.rb: -------------------------------------------------------------------------------- 1 | require "stringio" 2 | 3 | require_relative "./delta" 4 | require_relative "./numbers" 5 | 6 | module Pack 7 | class Expander 8 | 9 | attr_reader :source_size, :target_size 10 | 11 | def self.expand(source, delta) 12 | Expander.new(delta).expand(source) 13 | end 14 | 15 | def initialize(delta) 16 | @delta = StringIO.new(delta) 17 | 18 | @source_size = read_size 19 | @target_size = read_size 20 | end 21 | 22 | def expand(source) 23 | check_size(source, @source_size) 24 | target = "" 25 | 26 | until @delta.eof? 27 | byte = @delta.readbyte 28 | 29 | if byte < 0x80 30 | insert = Delta::Insert.parse(@delta, byte) 31 | target.concat(insert.data) 32 | else 33 | copy = Delta::Copy.parse(@delta, byte) 34 | size = (copy.size == 0) ? GIT_MAX_COPY : copy.size 35 | target.concat(source.byteslice(copy.offset, size)) 36 | end 37 | end 38 | 39 | check_size(target, @target_size) 40 | target 41 | end 42 | 43 | private 44 | 45 | def read_size 46 | Numbers::VarIntLE.read(@delta, 7)[1] 47 | end 48 | 49 | def check_size(buffer, size) 50 | raise "failed to apply delta" unless buffer.bytesize == size 51 | end 52 | 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/editor.rb: -------------------------------------------------------------------------------- 1 | require "shellwords" 2 | 3 | class Editor 4 | DEFAULT_EDITOR = "vi" 5 | 6 | def self.edit(path, command) 7 | editor = Editor.new(path, command) 8 | yield editor 9 | editor.edit_file 10 | end 11 | 12 | def initialize(path, command) 13 | @path = path 14 | @command = command || DEFAULT_EDITOR 15 | @closed = false 16 | end 17 | 18 | def puts(string) 19 | return if @closed 20 | file.puts(string) 21 | end 22 | 23 | def note(string) 24 | return if @closed 25 | string.each_line { |line| file.puts("# #{ line }") } 26 | end 27 | 28 | def close 29 | @closed = true 30 | end 31 | 32 | def edit_file 33 | file.close 34 | editor_argv = Shellwords.shellsplit(@command) + [@path.to_s] 35 | 36 | unless @closed or system(*editor_argv) 37 | raise "There was a problem with the editor '#{ @command }'." 38 | end 39 | 40 | remove_notes(File.read(@path)) 41 | end 42 | 43 | private 44 | 45 | def remove_notes(string) 46 | lines = string.lines.reject { |line| line.start_with?("#") } 47 | 48 | if lines.all? { |line| /^\s*$/ =~ line } 49 | nil 50 | else 51 | "#{ lines.join("").strip }\n" 52 | end 53 | end 54 | 55 | def file 56 | flags = File::WRONLY | File::CREAT | File::TRUNC 57 | @file ||= File.open(@path, flags) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/lockfile.rb: -------------------------------------------------------------------------------- 1 | class Lockfile 2 | LockDenied = Class.new(StandardError) 3 | MissingParent = Class.new(StandardError) 4 | NoPermission = Class.new(StandardError) 5 | StaleLock = Class.new(StandardError) 6 | 7 | def initialize(path) 8 | @file_path = path 9 | @lock_path = path.sub_ext(".lock") 10 | 11 | @lock = nil 12 | end 13 | 14 | def hold_for_update 15 | unless @lock 16 | flags = File::RDWR | File::CREAT | File::EXCL 17 | @lock = File.open(@lock_path, flags) 18 | end 19 | rescue Errno::EEXIST 20 | raise LockDenied, "Unable to create '#{ @lock_path }': File exists." 21 | rescue Errno::ENOENT => error 22 | raise MissingParent, error.message 23 | rescue Errno::EACCES => error 24 | raise NoPermission, error.message 25 | end 26 | 27 | def write(string) 28 | raise_on_stale_lock 29 | @lock.write(string) 30 | end 31 | 32 | def commit 33 | raise_on_stale_lock 34 | 35 | @lock.close 36 | File.rename(@lock_path, @file_path) 37 | @lock = nil 38 | end 39 | 40 | def rollback 41 | raise_on_stale_lock 42 | 43 | @lock.close 44 | File.unlink(@lock_path) 45 | @lock = nil 46 | end 47 | 48 | private 49 | 50 | def raise_on_stale_lock 51 | unless @lock 52 | raise StaleLock, "Not holding lock on file: #{ @lock_path }" 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/remotes/remote.rb: -------------------------------------------------------------------------------- 1 | require_relative "./refspec" 2 | 3 | class Remotes 4 | class Remote 5 | 6 | def initialize(config, name) 7 | @config = config 8 | @name = name 9 | 10 | @config.open 11 | end 12 | 13 | def fetch_url 14 | @config.get(["remote", @name, "url"]) 15 | end 16 | 17 | def fetch_specs 18 | @config.get_all(["remote", @name, "fetch"]) 19 | end 20 | 21 | def push_url 22 | @config.get(["remote", @name, "pushurl"]) || fetch_url 23 | end 24 | 25 | def push_specs 26 | @config.get_all(["remote", @name, "push"]) 27 | end 28 | 29 | def uploader 30 | @config.get(["remote", @name, "uploadpack"]) 31 | end 32 | 33 | def receiver 34 | @config.get(["remote", @name, "receivepack"]) 35 | end 36 | 37 | def get_upstream(branch) 38 | merge = @config.get(["branch", branch, "merge"]) 39 | targets = Refspec.expand(fetch_specs, [merge]) 40 | 41 | targets.keys.first 42 | end 43 | 44 | def set_upstream(branch, upstream) 45 | ref_name = Refspec.invert(fetch_specs, upstream) 46 | return nil unless ref_name 47 | 48 | @config.open_for_update 49 | @config.set(["branch", branch, "remote"], @name) 50 | @config.set(["branch", branch, "merge"], ref_name) 51 | @config.save 52 | 53 | ref_name 54 | end 55 | 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/merge/diff3_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "merge/diff3" 3 | 4 | describe Merge::Diff3 do 5 | it "cleanly merges two lists" do 6 | merge = Merge::Diff3.merge(%w[a b c], %w[d b c], %w[a b e]) 7 | assert merge.clean? 8 | assert_equal "dbe", merge.to_s 9 | end 10 | 11 | it "cleanly merges two lists with the same edit" do 12 | merge = Merge::Diff3.merge(%w[a b c], %w[d b c], %w[d b e]) 13 | assert merge.clean? 14 | assert_equal "dbe", merge.to_s 15 | end 16 | 17 | it "uncleanly merges two lists" do 18 | merge = Merge::Diff3.merge(%w[a b c], %w[d b c], %w[e b c]) 19 | refute merge.clean? 20 | 21 | assert_equal <<~STR.strip, merge.to_s 22 | <<<<<<< 23 | d======= 24 | e>>>>>>> 25 | bc 26 | STR 27 | end 28 | 29 | it "uncleanly merges two lists against an empty list" do 30 | merge = Merge::Diff3.merge([], %w[d b c], %w[e b c]) 31 | refute merge.clean? 32 | 33 | assert_equal <<~STR, merge.to_s 34 | <<<<<<< 35 | dbc======= 36 | ebc>>>>>>> 37 | STR 38 | end 39 | 40 | it "uncleanly merges two lists with head names" do 41 | merge = Merge::Diff3.merge(%w[a b c], %w[d b c], %w[e b c]) 42 | refute merge.clean? 43 | 44 | assert_equal <<~STR.strip, merge.to_s("left", "right") 45 | <<<<<<< left 46 | d======= 47 | e>>>>>>> right 48 | bc 49 | STR 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/database/backends.rb: -------------------------------------------------------------------------------- 1 | require "forwardable" 2 | 3 | require_relative "./loose" 4 | require_relative "./packed" 5 | 6 | class Database 7 | class Backends 8 | 9 | extend Forwardable 10 | def_delegators :@loose, :write_object 11 | 12 | def initialize(pathname) 13 | @pathname = pathname 14 | @loose = Loose.new(pathname) 15 | 16 | reload 17 | end 18 | 19 | def reload 20 | @stores = [@loose] + packed 21 | end 22 | 23 | def pack_path 24 | @pathname.join("pack") 25 | end 26 | 27 | def has?(oid) 28 | @stores.any? { |store| store.has?(oid) } 29 | end 30 | 31 | def load_info(oid) 32 | @stores.reduce(nil) { |info, store| info || store.load_info(oid) } 33 | end 34 | 35 | def load_raw(oid) 36 | @stores.reduce(nil) { |raw, store| raw || store.load_raw(oid) } 37 | end 38 | 39 | def prefix_match(name) 40 | oids = @stores.reduce([]) do |list, store| 41 | list + store.prefix_match(name) 42 | end 43 | 44 | oids.uniq 45 | end 46 | 47 | private 48 | 49 | def packed 50 | packs = Dir.entries(pack_path).grep(/\.pack$/) 51 | .map { |name| pack_path.join(name) } 52 | .sort_by { |path| File.mtime(path) } 53 | .reverse 54 | 55 | packs.map { |path| Packed.new(path) } 56 | 57 | rescue Errno::ENOENT 58 | [] 59 | end 60 | 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/pack/delta.rb: -------------------------------------------------------------------------------- 1 | require "forwardable" 2 | 3 | require_relative "./numbers" 4 | require_relative "./xdelta" 5 | 6 | module Pack 7 | class Delta 8 | 9 | Copy = Struct.new(:offset, :size) do 10 | def self.parse(input, byte) 11 | value = Numbers::PackedInt56LE.read(input, byte) 12 | offset = value & 0xffffffff 13 | size = value >> 32 14 | 15 | Copy.new(offset, size) 16 | end 17 | 18 | def to_s 19 | bytes = Numbers::PackedInt56LE.write((size << 32) | offset) 20 | bytes[0] |= 0x80 21 | bytes.pack("C*") 22 | end 23 | end 24 | 25 | Insert = Struct.new(:data) do 26 | def self.parse(input, byte) 27 | Insert.new(input.read(byte)) 28 | end 29 | 30 | def to_s 31 | [data.bytesize, data].pack("Ca*") 32 | end 33 | end 34 | 35 | extend Forwardable 36 | def_delegator :@data, :bytesize, :size 37 | 38 | attr_reader :base, :data 39 | 40 | def initialize(source, target) 41 | @base = source.entry 42 | @data = sizeof(source) + sizeof(target) 43 | 44 | source.delta_index ||= XDelta.create_index(source.data) 45 | 46 | delta = source.delta_index.compress(target.data) 47 | delta.each { |op| @data.concat(op.to_s) } 48 | end 49 | 50 | private 51 | 52 | def sizeof(entry) 53 | bytes = Numbers::VarIntLE.write(entry.size, 7) 54 | bytes.pack("C*") 55 | end 56 | 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/command/remote.rb: -------------------------------------------------------------------------------- 1 | require_relative "./base" 2 | 3 | module Command 4 | class Remote < Base 5 | 6 | def define_options 7 | @parser.on("-v", "--verbose") { @options[:verbose] = true } 8 | 9 | @options[:tracked] = [] 10 | @parser.on("-t ") { |branch| @options[:tracked].push(branch) } 11 | end 12 | 13 | def run 14 | case @args.shift 15 | when "add" then add_remote 16 | when "remove" then remove_remote 17 | else list_remotes 18 | end 19 | end 20 | 21 | private 22 | 23 | def add_remote 24 | name, url = @args[0], @args[1] 25 | repo.remotes.add(name, url, @options[:tracked]) 26 | exit 0 27 | rescue Remotes::InvalidRemote => error 28 | @stderr.puts "fatal: #{ error.message }" 29 | exit 128 30 | end 31 | 32 | def remove_remote 33 | repo.remotes.remove(@args[0]) 34 | exit 0 35 | rescue Remotes::InvalidRemote => error 36 | @stderr.puts "fatal: #{ error.message }" 37 | exit 128 38 | end 39 | 40 | def list_remotes 41 | repo.remotes.list_remotes.each { |name| list_remote(name) } 42 | exit 0 43 | end 44 | 45 | def list_remote(name) 46 | return puts name unless @options[:verbose] 47 | 48 | remote = repo.remotes.get(name) 49 | 50 | puts "#{ name }\t#{ remote.fetch_url } (fetch)" 51 | puts "#{ name }\t#{ remote.push_url } (push)" 52 | end 53 | 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/repository/inspector.rb: -------------------------------------------------------------------------------- 1 | class Repository 2 | class Inspector 3 | 4 | def initialize(repository) 5 | @repo = repository 6 | end 7 | 8 | def trackable_file?(path, stat) 9 | return false unless stat 10 | 11 | return !@repo.index.tracked_file?(path) if stat.file? 12 | return false unless stat.directory? 13 | 14 | items = @repo.workspace.list_dir(path) 15 | files = items.select { |_, item_stat| item_stat.file? } 16 | dirs = items.select { |_, item_stat| item_stat.directory? } 17 | 18 | [files, dirs].any? do |list| 19 | list.any? { |item_path, item_stat| trackable_file?(item_path, item_stat) } 20 | end 21 | end 22 | 23 | def compare_index_to_workspace(entry, stat) 24 | return :untracked unless entry 25 | return :deleted unless stat 26 | return :modified unless entry.stat_match?(stat) 27 | return nil if entry.times_match?(stat) 28 | 29 | data = @repo.workspace.read_file(entry.path) 30 | blob = Database::Blob.new(data) 31 | oid = @repo.database.hash_object(blob) 32 | 33 | unless entry.oid == oid 34 | :modified 35 | end 36 | end 37 | 38 | def compare_tree_to_index(item, entry) 39 | return nil unless item or entry 40 | return :added unless item 41 | return :deleted unless entry 42 | 43 | unless entry.mode == item.mode and entry.oid == item.oid 44 | :modified 45 | end 46 | end 47 | 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/repository.rb: -------------------------------------------------------------------------------- 1 | require_relative "./config/stack" 2 | require_relative "./database" 3 | require_relative "./index" 4 | require_relative "./refs" 5 | require_relative "./remotes" 6 | require_relative "./workspace" 7 | 8 | require_relative "./repository/divergence" 9 | require_relative "./repository/hard_reset" 10 | require_relative "./repository/migration" 11 | require_relative "./repository/pending_commit" 12 | require_relative "./repository/status" 13 | 14 | class Repository 15 | attr_reader :git_path 16 | 17 | def initialize(git_path) 18 | @git_path = git_path 19 | end 20 | 21 | def config 22 | @config ||= Config::Stack.new(@git_path) 23 | end 24 | 25 | def database 26 | @database ||= Database.new(@git_path.join("objects")) 27 | end 28 | 29 | def divergence(ref) 30 | Divergence.new(self, ref) 31 | end 32 | 33 | def hard_reset(oid) 34 | HardReset.new(self, oid).execute 35 | end 36 | 37 | def index 38 | @index ||= Index.new(@git_path.join("index")) 39 | end 40 | 41 | def migration(tree_diff) 42 | Migration.new(self, tree_diff) 43 | end 44 | 45 | def pending_commit 46 | PendingCommit.new(@git_path) 47 | end 48 | 49 | def refs 50 | @refs ||= Refs.new(@git_path) 51 | end 52 | 53 | def remotes 54 | @remotes ||= Remotes.new(config.file(:local)) 55 | end 56 | 57 | def status(commit_oid = nil) 58 | Status.new(self, commit_oid) 59 | end 60 | 61 | def workspace 62 | @workspace ||= Workspace.new(@git_path.dirname) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/index_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | 3 | require "pathname" 4 | require "securerandom" 5 | require "index" 6 | 7 | describe Index do 8 | let(:tmp_path) { File.expand_path("../tmp", __FILE__) } 9 | let(:index_path) { Pathname.new(tmp_path).join("index") } 10 | let(:index) { Index.new(index_path) } 11 | 12 | let(:stat) { File.stat(__FILE__) } 13 | let(:oid) { SecureRandom.hex(20) } 14 | 15 | it "adds a single file" do 16 | index.add("alice.txt", oid, stat) 17 | 18 | assert_equal ["alice.txt"], index.each_entry.map(&:path) 19 | end 20 | 21 | it "replaces a file with a directory" do 22 | index.add("alice.txt", oid, stat) 23 | index.add("bob.txt", oid, stat) 24 | 25 | index.add("alice.txt/nested.txt", oid, stat) 26 | 27 | assert_equal ["alice.txt/nested.txt", "bob.txt"], 28 | index.each_entry.map(&:path) 29 | end 30 | 31 | it "replaces a directory with a file" do 32 | index.add("alice.txt", oid, stat) 33 | index.add("nested/bob.txt", oid, stat) 34 | 35 | index.add("nested", oid, stat) 36 | 37 | assert_equal ["alice.txt", "nested"], 38 | index.each_entry.map(&:path) 39 | end 40 | 41 | it "recursively replaces a directory with a file" do 42 | index.add("alice.txt", oid, stat) 43 | index.add("nested/bob.txt", oid, stat) 44 | index.add("nested/inner/claire.txt", oid, stat) 45 | 46 | index.add("nested", oid, stat) 47 | 48 | assert_equal ["alice.txt", "nested"], 49 | index.each_entry.map(&:path) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/command/cherry_pick.rb: -------------------------------------------------------------------------------- 1 | require_relative "./base" 2 | require_relative "./shared/sequencing" 3 | require_relative "./shared/write_commit" 4 | require_relative "../merge/inputs" 5 | require_relative "../rev_list" 6 | 7 | module Command 8 | class CherryPick < Base 9 | 10 | include Sequencing 11 | include WriteCommit 12 | 13 | private 14 | 15 | def merge_type 16 | :cherry_pick 17 | end 18 | 19 | def store_commit_sequence 20 | commits = ::RevList.new(repo, @args.reverse, :walk => false) 21 | commits.reverse_each { |commit| sequencer.pick(commit) } 22 | end 23 | 24 | def pick(commit) 25 | inputs = pick_merge_inputs(commit) 26 | resolve_merge(inputs) 27 | fail_on_conflict(inputs, commit.message) if repo.index.conflict? 28 | 29 | picked = Database::Commit.new([inputs.left_oid], write_tree.oid, 30 | commit.author, current_author, 31 | commit.message) 32 | 33 | finish_commit(picked) 34 | end 35 | 36 | def pick_merge_inputs(commit) 37 | short = repo.database.short_oid(commit.oid) 38 | parent = select_parent(commit) 39 | 40 | left_name = Refs::HEAD 41 | left_oid = repo.refs.read_head 42 | right_name = "#{ short }... #{ commit.title_line.strip }" 43 | right_oid = commit.oid 44 | 45 | ::Merge::CherryPick.new(left_name, right_name, 46 | left_oid, right_oid, 47 | [parent]) 48 | end 49 | 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/pack/unpacker.rb: -------------------------------------------------------------------------------- 1 | require_relative "./expander" 2 | 3 | module Pack 4 | class Unpacker 5 | 6 | def initialize(database, reader, stream, progress) 7 | @database = database 8 | @reader = reader 9 | @stream = stream 10 | @progress = progress 11 | @offsets = {} 12 | end 13 | 14 | def process_pack 15 | @progress&.start("Unpacking objects", @reader.count) 16 | 17 | @reader.count.times do 18 | process_record 19 | @progress&.tick(@stream.offset) 20 | end 21 | @progress&.stop 22 | 23 | @stream.verify_checksum 24 | end 25 | 26 | private 27 | 28 | def process_record 29 | offset = @stream.offset 30 | record, _ = @stream.capture { @reader.read_record } 31 | 32 | record = resolve(record, offset) 33 | @database.store(record) 34 | @offsets[offset] = record.oid 35 | end 36 | 37 | def resolve(record, offset) 38 | case record 39 | when Record then record 40 | when OfsDelta then resolve_ofs_delta(record, offset) 41 | when RefDelta then resolve_ref_delta(record) 42 | end 43 | end 44 | 45 | def resolve_ofs_delta(delta, offset) 46 | oid = @offsets[offset - delta.base_ofs] 47 | resolve_delta(oid, delta.delta_data) 48 | end 49 | 50 | def resolve_ref_delta(delta) 51 | resolve_delta(delta.base_oid, delta.delta_data) 52 | end 53 | 54 | def resolve_delta(oid, delta_data) 55 | base = @database.load_raw(oid) 56 | data = Expander.expand(base.data, delta_data) 57 | 58 | Record.new(base.type, data) 59 | end 60 | 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/database/commit.rb: -------------------------------------------------------------------------------- 1 | require_relative "./author" 2 | 3 | class Database 4 | class Commit 5 | 6 | attr_accessor :oid 7 | attr_reader :parents, :tree, :author, :committer, :message 8 | 9 | def self.parse(scanner) 10 | headers = Hash.new { |hash, key| hash[key] = [] } 11 | 12 | loop do 13 | line = scanner.scan_until(/\n/).strip 14 | break if line == "" 15 | 16 | key, value = line.split(/ +/, 2) 17 | headers[key].push(value) 18 | end 19 | 20 | Commit.new( 21 | headers["parent"], 22 | headers["tree"].first, 23 | Author.parse(headers["author"].first), 24 | Author.parse(headers["committer"].first), 25 | scanner.rest) 26 | end 27 | 28 | def initialize(parents, tree, author, committer, message) 29 | @parents = parents 30 | @tree = tree 31 | @author = author 32 | @committer = committer 33 | @message = message 34 | end 35 | 36 | def merge? 37 | @parents.size > 1 38 | end 39 | 40 | def parent 41 | @parents.first 42 | end 43 | 44 | def date 45 | @committer.time 46 | end 47 | 48 | def title_line 49 | @message.lines.first 50 | end 51 | 52 | def type 53 | "commit" 54 | end 55 | 56 | def to_s 57 | lines = [] 58 | 59 | lines.push("tree #{ @tree }") 60 | lines.concat(@parents.map { |oid| "parent #{ oid }" }) 61 | lines.push("author #{ @author }") 62 | lines.push("committer #{ @committer }") 63 | lines.push("") 64 | lines.push(@message) 65 | 66 | lines.join("\n") 67 | end 68 | 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/repository/pending_commit.rb: -------------------------------------------------------------------------------- 1 | class Repository 2 | class PendingCommit 3 | 4 | Error = Class.new(StandardError) 5 | 6 | HEAD_FILES = { 7 | :merge => "MERGE_HEAD", 8 | :cherry_pick => "CHERRY_PICK_HEAD", 9 | :revert => "REVERT_HEAD" 10 | } 11 | 12 | attr_reader :message_path 13 | 14 | def initialize(pathname) 15 | @pathname = pathname 16 | @message_path = pathname.join("MERGE_MSG") 17 | end 18 | 19 | def start(oid, type = :merge) 20 | path = @pathname.join(HEAD_FILES.fetch(type)) 21 | flags = File::WRONLY | File::CREAT | File::EXCL 22 | File.open(path, flags) { |f| f.puts(oid) } 23 | end 24 | 25 | def in_progress? 26 | merge_type != nil 27 | end 28 | 29 | def merge_type 30 | HEAD_FILES.each do |type, name| 31 | path = @pathname.join(name) 32 | return type if File.file?(path) 33 | end 34 | 35 | nil 36 | end 37 | 38 | def merge_oid(type = :merge) 39 | head_path = @pathname.join(HEAD_FILES.fetch(type)) 40 | File.read(head_path).strip 41 | rescue Errno::ENOENT 42 | name = head_path.basename 43 | raise Error, "There is no merge in progress (#{ name } missing)." 44 | end 45 | 46 | def merge_message 47 | File.read(@message_path) 48 | end 49 | 50 | def clear(type = :merge) 51 | head_path = @pathname.join(HEAD_FILES.fetch(type)) 52 | File.unlink(head_path) 53 | File.unlink(@message_path) 54 | rescue Errno::ENOENT 55 | name = head_path.basename 56 | raise Error, "There is no merge to abort (#{ name } missing)." 57 | end 58 | 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/database/tree_diff.rb: -------------------------------------------------------------------------------- 1 | class Database 2 | class TreeDiff 3 | 4 | attr_reader :changes 5 | 6 | def initialize(database) 7 | @database = database 8 | @changes = {} 9 | end 10 | 11 | def compare_oids(a, b, filter) 12 | return if a == b 13 | 14 | a_entries = a ? oid_to_tree(a).entries : {} 15 | b_entries = b ? oid_to_tree(b).entries : {} 16 | 17 | detect_deletions(a_entries, b_entries, filter) 18 | detect_additions(a_entries, b_entries, filter) 19 | end 20 | 21 | private 22 | 23 | def oid_to_tree(oid) 24 | object = @database.load(oid) 25 | 26 | case object 27 | when Commit then @database.load(object.tree) 28 | when Tree then object 29 | end 30 | end 31 | 32 | def detect_deletions(a, b, filter) 33 | filter.each_entry(a) do |name, entry| 34 | other = b[name] 35 | next if entry == other 36 | 37 | sub_filter = filter.join(name) 38 | 39 | tree_a, tree_b = [entry, other].map { |e| e&.tree? ? e.oid : nil } 40 | compare_oids(tree_a, tree_b, sub_filter) 41 | 42 | blobs = [entry, other].map { |e| e&.tree? ? nil : e } 43 | @changes[sub_filter.path] = blobs if blobs.any? 44 | end 45 | end 46 | 47 | def detect_additions(a, b, filter) 48 | filter.each_entry(b) do |name, entry| 49 | other = a[name] 50 | next if other 51 | 52 | sub_filter = filter.join(name) 53 | 54 | if entry.tree? 55 | compare_oids(nil, entry.oid, sub_filter) 56 | else 57 | @changes[sub_filter.path] = [nil, entry] 58 | end 59 | end 60 | end 61 | 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/command/reset.rb: -------------------------------------------------------------------------------- 1 | require "pathname" 2 | 3 | require_relative "./base" 4 | require_relative "../revision" 5 | 6 | module Command 7 | class Reset < Base 8 | 9 | def define_options 10 | @options[:mode] = :mixed 11 | 12 | @parser.on("--soft") { @options[:mode] = :soft } 13 | @parser.on("--mixed") { @options[:mode] = :mixed } 14 | @parser.on("--hard") { @options[:mode] = :hard } 15 | end 16 | 17 | def run 18 | select_commit_oid 19 | 20 | repo.index.load_for_update 21 | reset_files 22 | repo.index.write_updates 23 | 24 | if @args.empty? 25 | head_oid = repo.refs.update_head(@commit_oid) 26 | repo.refs.update_ref(Refs::ORIG_HEAD, head_oid) 27 | end 28 | 29 | exit 0 30 | end 31 | 32 | private 33 | 34 | def select_commit_oid 35 | revision = @args.fetch(0, Revision::HEAD) 36 | @commit_oid = Revision.new(repo, revision).resolve 37 | @args.shift 38 | rescue Revision::InvalidObject 39 | @commit_oid = repo.refs.read_head 40 | end 41 | 42 | def reset_files 43 | return if @options[:mode] == :soft 44 | return repo.hard_reset(@commit_oid) if @options[:mode] == :hard 45 | 46 | if @args.empty? 47 | repo.index.clear! 48 | reset_path(nil) 49 | else 50 | @args.each { |path| reset_path(Pathname.new(path)) } 51 | end 52 | end 53 | 54 | def reset_path(pathname) 55 | listing = repo.database.load_tree_list(@commit_oid, pathname) 56 | repo.index.remove(pathname) if pathname 57 | 58 | listing.each do |path, entry| 59 | repo.index.add_from_db(path, entry) 60 | end 61 | end 62 | 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/database/tree.rb: -------------------------------------------------------------------------------- 1 | class Database 2 | class Tree 3 | 4 | ENTRY_FORMAT = "Z*H40" 5 | TREE_MODE = 040000 6 | 7 | attr_accessor :oid 8 | attr_reader :entries 9 | 10 | def self.parse(scanner) 11 | entries = {} 12 | 13 | until scanner.eos? 14 | mode = scanner.scan_until(/ /).strip.to_i(8) 15 | name = scanner.scan_until(/\0/)[0..-2] 16 | 17 | oid = scanner.peek(20).unpack("H40").first 18 | scanner.pos += 20 19 | 20 | entries[name] = Entry.new(oid, mode) 21 | end 22 | 23 | Tree.new(entries) 24 | end 25 | 26 | def self.build(entries) 27 | root = Tree.new 28 | 29 | entries.each do |entry| 30 | root.add_entry(entry.parent_directories, entry) 31 | end 32 | 33 | root 34 | end 35 | 36 | def initialize(entries = {}) 37 | @entries = entries 38 | end 39 | 40 | def add_entry(parents, entry) 41 | if parents.empty? 42 | @entries[entry.basename] = entry 43 | else 44 | tree = @entries[parents.first.basename] ||= Tree.new 45 | tree.add_entry(parents.drop(1), entry) 46 | end 47 | end 48 | 49 | def traverse(&block) 50 | @entries.each do |name, entry| 51 | entry.traverse(&block) if entry.is_a?(Tree) 52 | end 53 | block.call(self) 54 | end 55 | 56 | def mode 57 | TREE_MODE 58 | end 59 | 60 | def type 61 | "tree" 62 | end 63 | 64 | def to_s 65 | entries = @entries.map do |name, entry| 66 | mode = entry.mode.to_s(8) 67 | ["#{ mode } #{ name }", entry.oid].pack(ENTRY_FORMAT) 68 | end 69 | 70 | entries.join("") 71 | end 72 | 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/diff/hunk.rb: -------------------------------------------------------------------------------- 1 | module Diff 2 | 3 | HUNK_CONTEXT = 3 4 | 5 | Hunk = Struct.new(:a_starts, :b_start, :edits) do 6 | def self.filter(edits) 7 | hunks = [] 8 | offset = 0 9 | 10 | loop do 11 | offset += 1 while edits[offset]&.type == :eql 12 | return hunks if offset >= edits.size 13 | 14 | offset -= HUNK_CONTEXT + 1 15 | 16 | a_starts = (offset < 0) ? [] : edits[offset].a_lines.map(&:number) 17 | b_start = (offset < 0) ? nil : edits[offset].b_line.number 18 | 19 | hunks.push(Hunk.new(a_starts, b_start, [])) 20 | offset = Hunk.build(hunks.last, edits, offset) 21 | end 22 | end 23 | 24 | def self.build(hunk, edits, offset) 25 | counter = -1 26 | 27 | until counter == 0 28 | hunk.edits.push(edits[offset]) if offset >= 0 and counter > 0 29 | 30 | offset += 1 31 | break if offset >= edits.size 32 | 33 | case edits[offset + HUNK_CONTEXT]&.type 34 | when :ins, :del 35 | counter = 2 * HUNK_CONTEXT + 1 36 | else 37 | counter -= 1 38 | end 39 | end 40 | 41 | offset 42 | end 43 | 44 | def header 45 | a_lines = edits.map(&:a_lines).transpose 46 | offsets = a_lines.map.with_index { |lines, i| format("-", lines, a_starts[i]) } 47 | 48 | offsets.push(format("+", edits.map(&:b_line), b_start)) 49 | sep = "@" * offsets.size 50 | 51 | [sep, *offsets, sep].join(" ") 52 | end 53 | 54 | private 55 | 56 | def format(sign, lines, start) 57 | lines = lines.compact 58 | start = lines.first&.number || start || 0 59 | 60 | "#{ sign }#{ start },#{ lines.size }" 61 | end 62 | end 63 | 64 | end 65 | -------------------------------------------------------------------------------- /lib/progress.rb: -------------------------------------------------------------------------------- 1 | class Progress 2 | UNITS = ["B", "KiB", "MiB", "GiB"] 3 | SCALE = 1024.0 4 | 5 | def initialize(output) 6 | @output = output 7 | @message = nil 8 | end 9 | 10 | def start(message, total = nil) 11 | return if ENV["NO_PROGRESS"] or not @output.isatty 12 | 13 | @message = message 14 | @total = total 15 | @count = 0 16 | @bytes = 0 17 | @write_at = get_time 18 | end 19 | 20 | def tick(bytes = 0) 21 | return unless @message 22 | 23 | @count += 1 24 | @bytes = bytes 25 | 26 | current_time = get_time 27 | return if current_time < @write_at + 0.05 28 | @write_at = current_time 29 | 30 | clear_line 31 | @output.write(status_line) 32 | end 33 | 34 | def stop 35 | return unless @message 36 | 37 | @total = @count 38 | 39 | clear_line 40 | @output.puts(status_line) 41 | @message = nil 42 | end 43 | 44 | private 45 | 46 | def get_time 47 | Process.clock_gettime(Process::CLOCK_MONOTONIC) 48 | end 49 | 50 | def clear_line 51 | @output.write("\e[G\e[K") 52 | end 53 | 54 | def status_line 55 | line = "#{ @message }: #{ format_count }" 56 | 57 | line.concat(", #{ format_bytes }") if @bytes > 0 58 | line.concat(", done.") if @count == @total 59 | 60 | line 61 | end 62 | 63 | def format_count 64 | if @total 65 | percent = (@total == 0) ? 100 : 100 * @count / @total 66 | "#{ percent }% (#{ @count }/#{ @total })" 67 | else 68 | "(#{ @count })" 69 | end 70 | end 71 | 72 | def format_bytes 73 | power = Math.log(@bytes, SCALE).floor 74 | scaled = @bytes / (SCALE ** power) 75 | 76 | format("%.2f #{ UNITS[power] }", scaled) 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/diff/combined.rb: -------------------------------------------------------------------------------- 1 | module Diff 2 | class Combined 3 | 4 | include Enumerable 5 | 6 | Row = Struct.new(:edits) do 7 | def type 8 | types = edits.compact.map(&:type) 9 | types.include?(:ins) ? :ins : types.first 10 | end 11 | 12 | def a_lines 13 | edits.map { |edit| edit&.a_line } 14 | end 15 | 16 | def b_line 17 | edits.first&.b_line 18 | end 19 | 20 | def to_s 21 | symbols = edits.map { |edit| SYMBOLS.fetch(edit&.type, " ") } 22 | 23 | del = edits.find { |edit| edit&.type == :del } 24 | line = del ? del.a_line : edits.first.b_line 25 | 26 | symbols.join("") + line.text 27 | end 28 | end 29 | 30 | def initialize(diffs) 31 | @diffs = diffs 32 | end 33 | 34 | def each 35 | @offsets = @diffs.map { 0 } 36 | 37 | loop do 38 | @diffs.each_with_index do |diff, i| 39 | consume_deletions(diff, i) { |row| yield row } 40 | end 41 | 42 | return if complete? 43 | 44 | edits = offset_diffs.map { |offset, diff| diff[offset] } 45 | @offsets.map! { |offset| offset + 1 } 46 | 47 | yield Row.new(edits) 48 | end 49 | end 50 | 51 | private 52 | 53 | def complete? 54 | offset_diffs.all? { |offset, diff| offset == diff.size } 55 | end 56 | 57 | def offset_diffs 58 | @offsets.zip(@diffs) 59 | end 60 | 61 | def consume_deletions(diff, i) 62 | while @offsets[i] < diff.size and diff[@offsets[i]].type == :del 63 | edits = Array.new(@diffs.size) 64 | edits[i] = diff[@offsets[i]] 65 | @offsets[i] += 1 66 | 67 | yield Row.new(edits) 68 | end 69 | end 70 | 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/database/loose.rb: -------------------------------------------------------------------------------- 1 | require "strscan" 2 | require "zlib" 3 | 4 | require_relative "../temp_file" 5 | 6 | class Database 7 | class Loose 8 | 9 | def initialize(pathname) 10 | @pathname = pathname 11 | end 12 | 13 | def has?(oid) 14 | File.file?(object_path(oid)) 15 | end 16 | 17 | def load_info(oid) 18 | type, size, _ = read_object_header(oid, 128) 19 | Raw.new(type, size) 20 | rescue Errno::ENOENT 21 | nil 22 | end 23 | 24 | def load_raw(oid) 25 | type, size, scanner = read_object_header(oid) 26 | Raw.new(type, size, scanner.rest) 27 | rescue Errno::ENOENT 28 | nil 29 | end 30 | 31 | def prefix_match(name) 32 | dirname = object_path(name).dirname 33 | 34 | oids = Dir.entries(dirname).map do |filename| 35 | "#{ dirname.basename }#{ filename }" 36 | end 37 | 38 | oids.select { |oid| oid.start_with?(name) } 39 | rescue Errno::ENOENT 40 | [] 41 | end 42 | 43 | def write_object(oid, content) 44 | path = object_path(oid) 45 | return if File.exist?(path) 46 | 47 | file = TempFile.new(path.dirname, "tmp_obj") 48 | file.write(Zlib::Deflate.deflate(content, Zlib::BEST_SPEED)) 49 | file.move(path.basename) 50 | end 51 | 52 | private 53 | 54 | def object_path(oid) 55 | @pathname.join(oid[0..1], oid[2..-1]) 56 | end 57 | 58 | def read_object_header(oid, read_bytes = nil) 59 | path = object_path(oid) 60 | data = Zlib::Inflate.new.inflate(File.read(path, read_bytes)) 61 | scanner = StringScanner.new(data) 62 | 63 | type = scanner.scan_until(/ /).strip 64 | size = scanner.scan_until(/\0/)[0..-2].to_i 65 | 66 | [type, size, scanner] 67 | end 68 | 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/diff_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "diff" 3 | 4 | describe Diff do 5 | def hunks(a, b) 6 | Diff.diff_hunks(a, b).map { |hunk| [hunk.header, hunk.edits.map(&:to_s)] } 7 | end 8 | 9 | doc = %w[the quick brown fox jumps over the lazy dog] 10 | 11 | it "detects a deletion at the start" do 12 | changed = %w[quick brown fox jumps over the lazy dog] 13 | 14 | assert_equal [ 15 | ["@@ -1,4 +1,3 @@", [ 16 | "-the", " quick", " brown", " fox" 17 | ]] 18 | ], hunks(doc, changed) 19 | end 20 | 21 | it "detects an insertion at the start" do 22 | changed = %w[so the quick brown fox jumps over the lazy dog] 23 | 24 | assert_equal [ 25 | ["@@ -1,3 +1,4 @@", [ 26 | "+so", " the", " quick", " brown" 27 | ]] 28 | ], hunks(doc, changed) 29 | end 30 | 31 | it "detects a change skipping the start and end" do 32 | changed = %w[the quick brown fox leaps right over the lazy dog] 33 | 34 | assert_equal [ 35 | ["@@ -2,7 +2,8 @@", [ 36 | " quick", " brown", " fox", "-jumps", "+leaps", "+right", " over", " the", " lazy" 37 | ]] 38 | ], hunks(doc, changed) 39 | end 40 | 41 | it "puts nearby changes in the same hunk" do 42 | changed = %w[the brown fox jumps over the lazy cat] 43 | 44 | assert_equal [ 45 | ["@@ -1,9 +1,8 @@", [ 46 | " the", "-quick", " brown", " fox", " jumps", " over", " the", " lazy", "-dog", "+cat" 47 | ]] 48 | ], hunks(doc, changed) 49 | end 50 | 51 | it "puts distant changes in different hunks" do 52 | changed = %w[a quick brown fox jumps over the lazy cat] 53 | 54 | assert_equal [ 55 | ["@@ -1,4 +1,4 @@", [ 56 | "-the", "+a", " quick", " brown", " fox" 57 | ]], 58 | ["@@ -6,4 +6,4 @@", [ 59 | " over", " the", " lazy", "-dog", "+cat" 60 | ]] 61 | ], hunks(doc, changed) 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/command/add.rb: -------------------------------------------------------------------------------- 1 | require "pathname" 2 | require_relative "./base" 3 | 4 | module Command 5 | class Add < Base 6 | 7 | LOCKED_INDEX_MESSAGE = <<~MSG 8 | Another jit process seems to be running in this repository. 9 | Please make sure all processes are terminated then try again. 10 | If it still fails, a jit process may have crashed in this 11 | repository earlier: remove the file manually to continue. 12 | MSG 13 | 14 | def run 15 | repo.index.load_for_update 16 | expanded_paths.each { |path| add_to_index(path) } 17 | repo.index.write_updates 18 | exit 0 19 | rescue Lockfile::LockDenied => error 20 | handle_locked_index(error) 21 | rescue Workspace::MissingFile => error 22 | handle_missing_file(error) 23 | rescue Workspace::NoPermission => error 24 | handle_unreadable_file(error) 25 | end 26 | 27 | private 28 | 29 | def expanded_paths 30 | @args.flat_map do |path| 31 | repo.workspace.list_files(expanded_pathname(path)) 32 | end 33 | end 34 | 35 | def add_to_index(path) 36 | data = repo.workspace.read_file(path) 37 | stat = repo.workspace.stat_file(path) 38 | 39 | blob = Database::Blob.new(data) 40 | repo.database.store(blob) 41 | repo.index.add(path, blob.oid, stat) 42 | end 43 | 44 | def handle_locked_index(error) 45 | @stderr.puts "fatal: #{ error.message }" 46 | @stderr.puts 47 | @stderr.puts LOCKED_INDEX_MESSAGE 48 | exit 128 49 | end 50 | 51 | def handle_missing_file(error) 52 | @stderr.puts "fatal: #{ error.message }" 53 | repo.index.release_lock 54 | exit 128 55 | end 56 | 57 | def handle_unreadable_file(error) 58 | @stderr.puts "error: #{ error.message }" 59 | @stderr.puts "fatal: adding files failed" 60 | repo.index.release_lock 61 | exit 128 62 | end 63 | 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/pack/stream.rb: -------------------------------------------------------------------------------- 1 | require "digest/sha1" 2 | 3 | module Pack 4 | class Stream 5 | 6 | attr_reader :digest, :offset 7 | 8 | def initialize(input, buffer = "") 9 | @input = input 10 | @digest = Digest::SHA1.new 11 | @offset = 0 12 | @buffer = new_byte_string.concat(buffer) 13 | @capture = nil 14 | end 15 | 16 | def capture 17 | @capture = new_byte_string 18 | result = [yield, @capture] 19 | 20 | @digest.update(@capture) 21 | @capture = nil 22 | 23 | result 24 | end 25 | 26 | def verify_checksum 27 | unless read_buffered(20) == @digest.digest 28 | raise InvalidPack, "Checksum does not match value read from pack" 29 | end 30 | end 31 | 32 | def read(size) 33 | data = read_buffered(size) 34 | update_state(data) 35 | data 36 | end 37 | 38 | def read_nonblock(size) 39 | data = read_buffered(size, false) 40 | update_state(data) 41 | data 42 | end 43 | 44 | def readbyte 45 | read(1).bytes.first 46 | end 47 | 48 | def seek(amount, whence = IO::SEEK_SET) 49 | return unless amount < 0 50 | 51 | data = @capture.slice!(amount .. -1) 52 | @buffer.prepend(data) 53 | @offset += amount 54 | end 55 | 56 | private 57 | 58 | def new_byte_string 59 | String.new("", :encoding => Encoding::ASCII_8BIT) 60 | end 61 | 62 | def read_buffered(size, block = true) 63 | from_buf = @buffer.slice!(0, size) 64 | needed = size - from_buf.bytesize 65 | from_io = block ? @input.read(needed) : @input.read_nonblock(needed) 66 | 67 | from_buf.concat(from_io.to_s) 68 | 69 | rescue EOFError, Errno::EWOULDBLOCK 70 | from_buf 71 | end 72 | 73 | def update_state(data) 74 | @digest.update(data) unless @capture 75 | @offset += data.bytesize 76 | @capture&.concat(data) 77 | end 78 | 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/pack/numbers.rb: -------------------------------------------------------------------------------- 1 | module Pack 2 | module Numbers 3 | 4 | module VarIntLE 5 | def self.write(value, shift) 6 | bytes = [] 7 | mask = 2 ** shift - 1 8 | 9 | until value <= mask 10 | bytes.push(0x80 | value & mask) 11 | value >>= shift 12 | 13 | mask, shift = 0x7f, 7 14 | end 15 | 16 | bytes + [value] 17 | end 18 | 19 | def self.read(input, shift) 20 | first = input.readbyte 21 | value = first & (2 ** shift - 1) 22 | 23 | byte = first 24 | 25 | until byte < 0x80 26 | byte = input.readbyte 27 | value |= (byte & 0x7f) << shift 28 | shift += 7 29 | end 30 | 31 | [first, value] 32 | end 33 | end 34 | 35 | module VarIntBE 36 | def self.write(value) 37 | bytes = [value & 0x7f] 38 | 39 | until (value >>= 7) == 0 40 | value -= 1 41 | bytes.push(0x80 | value & 0x7f) 42 | end 43 | 44 | bytes.reverse.pack("C*") 45 | end 46 | 47 | def self.read(input) 48 | byte = input.readbyte 49 | value = byte & 0x7f 50 | 51 | until byte < 0x80 52 | byte = input.readbyte 53 | value = ((value + 1) << 7) | (byte & 0x7f) 54 | end 55 | 56 | value 57 | end 58 | end 59 | 60 | module PackedInt56LE 61 | def self.write(value) 62 | bytes = [0] 63 | 64 | (0...7).each do |i| 65 | byte = (value >> (8 * i)) & 0xff 66 | next if byte == 0 67 | 68 | bytes[0] |= 1 << i 69 | bytes.push(byte) 70 | end 71 | 72 | bytes 73 | end 74 | 75 | def self.read(input, header) 76 | value = 0 77 | 78 | (0...7).each do |i| 79 | next if header & (1 << i) == 0 80 | value |= input.readbyte << (8 * i) 81 | end 82 | 83 | value 84 | end 85 | end 86 | 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/remotes/protocol.rb: -------------------------------------------------------------------------------- 1 | class Remotes 2 | class Protocol 3 | 4 | attr_reader :input, :output 5 | 6 | def initialize(command, input, output, capabilities = []) 7 | @command = command 8 | @input = input 9 | @output = output 10 | 11 | @input.sync = @output.sync = true 12 | 13 | @caps_local = capabilities 14 | @caps_remote = nil 15 | @caps_sent = false 16 | end 17 | 18 | def capable?(ability) 19 | @caps_remote&.include?(ability) 20 | end 21 | 22 | def send_packet(line) 23 | return @output.write("0000") if line == nil 24 | 25 | line = append_caps(line) 26 | size = line.bytesize + 5 27 | 28 | @output.write(size.to_s(16).rjust(4, "0")) 29 | @output.write(line) 30 | @output.write("\n") 31 | end 32 | 33 | def recv_packet 34 | head = @input.read(4) 35 | return head unless /[0-9a-f]{4}/ =~ head 36 | 37 | size = head.to_i(16) 38 | return nil if size == 0 39 | 40 | line = @input.read(size - 4).sub(/\n$/, "") 41 | detect_caps(line) 42 | end 43 | 44 | def recv_until(terminator) 45 | loop do 46 | line = recv_packet 47 | break if line == terminator 48 | yield line 49 | end 50 | end 51 | 52 | private 53 | 54 | def append_caps(line) 55 | return line if @caps_sent 56 | @caps_sent = true 57 | 58 | sep = (@command == "fetch") ? " " : "\0" 59 | caps = @caps_local 60 | caps &= @caps_remote if @caps_remote 61 | 62 | line + sep + caps.join(" ") 63 | end 64 | 65 | def detect_caps(line) 66 | return line if @caps_remote 67 | 68 | if @command == "upload-pack" 69 | sep, n = " ", 3 70 | else 71 | sep, n = "\0", 2 72 | end 73 | 74 | parts = line.split(sep, n) 75 | caps = (parts.size == n) ? parts.pop : "" 76 | 77 | @caps_remote = caps.split(/ +/) 78 | parts.join(" ") 79 | end 80 | 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/database/packed.rb: -------------------------------------------------------------------------------- 1 | require "forwardable" 2 | require_relative "../pack" 3 | 4 | class Database 5 | class Packed 6 | 7 | extend Forwardable 8 | def_delegators :@index, :prefix_match 9 | 10 | def initialize(pathname) 11 | @pack_file = File.open(pathname, File::RDONLY) 12 | @reader = Pack::Reader.new(@pack_file) 13 | 14 | @index_file = File.open(pathname.sub_ext(".idx"), File::RDONLY) 15 | @index = Pack::Index.new(@index_file) 16 | end 17 | 18 | def has?(oid) 19 | @index.oid_offset(oid) != nil 20 | end 21 | 22 | def load_info(oid) 23 | offset = @index.oid_offset(oid) 24 | offset ? load_info_at(offset) : nil 25 | end 26 | 27 | def load_raw(oid) 28 | offset = @index.oid_offset(oid) 29 | offset ? load_raw_at(offset) : nil 30 | end 31 | 32 | private 33 | 34 | def load_info_at(offset) 35 | @pack_file.seek(offset) 36 | record = @reader.read_info 37 | 38 | case record 39 | when Pack::Record 40 | Raw.new(record.type, record.data) 41 | when Pack::OfsDelta 42 | base = load_info_at(offset - record.base_ofs) 43 | Raw.new(base.type, record.delta_data) 44 | when Pack::RefDelta 45 | base = load_info(record.base_oid) 46 | Raw.new(base.type, record.delta_data) 47 | end 48 | end 49 | 50 | def load_raw_at(offset) 51 | @pack_file.seek(offset) 52 | record = @reader.read_record 53 | 54 | case record 55 | when Pack::Record 56 | record 57 | when Pack::OfsDelta 58 | base = load_raw_at(offset - record.base_ofs) 59 | expand_delta(base, record) 60 | when Pack::RefDelta 61 | base = load_raw(record.base_oid) 62 | expand_delta(base, record) 63 | end 64 | end 65 | 66 | def expand_delta(base, record) 67 | data = Pack::Expander.expand(base.data, record.delta_data) 68 | Pack::Record.new(base.type, data) 69 | end 70 | 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/command.rb: -------------------------------------------------------------------------------- 1 | require_relative "./command/add" 2 | require_relative "./command/branch" 3 | require_relative "./command/checkout" 4 | require_relative "./command/cherry_pick" 5 | require_relative "./command/commit" 6 | require_relative "./command/config" 7 | require_relative "./command/diff" 8 | require_relative "./command/fetch" 9 | require_relative "./command/init" 10 | require_relative "./command/log" 11 | require_relative "./command/merge" 12 | require_relative "./command/push" 13 | require_relative "./command/remote" 14 | require_relative "./command/receive_pack" 15 | require_relative "./command/reset" 16 | require_relative "./command/rev_list" 17 | require_relative "./command/revert" 18 | require_relative "./command/rm" 19 | require_relative "./command/status" 20 | require_relative "./command/upload_pack" 21 | 22 | module Command 23 | Unknown = Class.new(StandardError) 24 | 25 | COMMANDS = { 26 | "init" => Init, 27 | "config" => Config, 28 | "add" => Add, 29 | "rm" => Rm, 30 | "commit" => Commit, 31 | "status" => Status, 32 | "diff" => Diff, 33 | "branch" => Branch, 34 | "checkout" => Checkout, 35 | "reset" => Reset, 36 | "rev-list" => RevList, 37 | "log" => Log, 38 | "merge" => Merge, 39 | "cherry-pick" => CherryPick, 40 | "revert" => Revert, 41 | "remote" => Remote, 42 | "fetch" => Fetch, 43 | "push" => Push, 44 | "upload-pack" => UploadPack, 45 | "receive-pack" => ReceivePack 46 | } 47 | 48 | def self.execute(dir, env, argv, stdin, stdout, stderr) 49 | name = argv.first 50 | args = argv.drop(1) 51 | 52 | unless COMMANDS.has_key?(name) 53 | raise Unknown, "'#{ name }' is not a jit command." 54 | end 55 | 56 | command_class = COMMANDS[name] 57 | command = command_class.new(dir, env, args, stdin, stdout, stderr) 58 | 59 | command.execute 60 | command 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/command/revert.rb: -------------------------------------------------------------------------------- 1 | require_relative "./base" 2 | require_relative "./shared/sequencing" 3 | require_relative "./shared/write_commit" 4 | require_relative "../merge/inputs" 5 | require_relative "../rev_list" 6 | 7 | module Command 8 | class Revert < Base 9 | 10 | include Sequencing 11 | include WriteCommit 12 | 13 | private 14 | 15 | def merge_type 16 | :revert 17 | end 18 | 19 | def store_commit_sequence 20 | commits = ::RevList.new(repo, @args, :walk => false) 21 | commits.each { |commit| sequencer.revert(commit) } 22 | end 23 | 24 | def revert(commit) 25 | inputs = revert_merge_inputs(commit) 26 | message = revert_commit_message(commit) 27 | 28 | resolve_merge(inputs) 29 | fail_on_conflict(inputs, message) if repo.index.conflict? 30 | 31 | author = current_author 32 | message = edit_revert_message(message) 33 | picked = Database::Commit.new([inputs.left_oid], write_tree.oid, 34 | author, author, message) 35 | 36 | finish_commit(picked) 37 | end 38 | 39 | def revert_merge_inputs(commit) 40 | short = repo.database.short_oid(commit.oid) 41 | 42 | left_name = Refs::HEAD 43 | left_oid = repo.refs.read_head 44 | right_name = "parent of #{ short }... #{ commit.title_line.strip }" 45 | right_oid = select_parent(commit) 46 | 47 | ::Merge::CherryPick.new(left_name, right_name, 48 | left_oid, right_oid, 49 | [commit.oid]) 50 | end 51 | 52 | def revert_commit_message(commit) 53 | <<~MESSAGE 54 | Revert "#{ commit.title_line.strip }" 55 | 56 | This reverts commit #{ commit.oid }. 57 | MESSAGE 58 | end 59 | 60 | def edit_revert_message(message) 61 | edit_file(commit_message_path) do |editor| 62 | editor.puts(message) 63 | editor.puts("") 64 | editor.note(Commit::COMMIT_NOTES) 65 | end 66 | end 67 | 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/remotes/refspec.rb: -------------------------------------------------------------------------------- 1 | require "pathname" 2 | 3 | require_relative "../refs" 4 | require_relative "../revision" 5 | 6 | class Remotes 7 | 8 | REFSPEC_FORMAT = /^(\+?)([^:]*)(:([^:]*))?$/ 9 | 10 | Refspec = Struct.new(:source, :target, :forced) do 11 | def self.parse(spec) 12 | match = REFSPEC_FORMAT.match(spec) 13 | source = Refspec.canonical(match[2]) 14 | target = Refspec.canonical(match[4]) || source 15 | 16 | Refspec.new(source, target, match[1] == "+") 17 | end 18 | 19 | def self.canonical(name) 20 | return nil if name.to_s == "" 21 | return name unless Revision.valid_ref?(name) 22 | 23 | first = Pathname.new(name).descend.first 24 | dirs = [Refs::REFS_DIR, Refs::HEADS_DIR, Refs::REMOTES_DIR] 25 | prefix = dirs.find { |dir| dir.basename == first } 26 | 27 | (prefix&.dirname || Refs::HEADS_DIR).join(name).to_s 28 | end 29 | 30 | def self.expand(specs, refs) 31 | specs = specs.map { |spec| Refspec.parse(spec) } 32 | 33 | specs.reduce({}) do |mappings, spec| 34 | mappings.merge(spec.match_refs(refs)) 35 | end 36 | end 37 | 38 | def self.invert(specs, ref) 39 | specs = specs.map { |spec| Refspec.parse(spec) } 40 | 41 | map = specs.reduce({}) do |mappings, spec| 42 | spec.source, spec.target = spec.target, spec.source 43 | mappings.merge(spec.match_refs([ref])) 44 | end 45 | 46 | map.keys.first 47 | end 48 | 49 | def match_refs(refs) 50 | return { target => [source, forced] } unless source.to_s.include?("*") 51 | 52 | pattern = /^#{ source.sub("*", "(.*)") }$/ 53 | mappings = {} 54 | 55 | refs.each do |ref| 56 | next unless match = pattern.match(ref) 57 | dst = match[1] ? target.sub("*", match[1]) : target 58 | mappings[dst] = [ref, forced] 59 | end 60 | 61 | mappings 62 | end 63 | 64 | def to_s 65 | spec = forced ? "+" : "" 66 | spec + [source, target].join(":") 67 | end 68 | end 69 | 70 | end 71 | -------------------------------------------------------------------------------- /lib/merge/common_ancestors.rb: -------------------------------------------------------------------------------- 1 | require "set" 2 | 3 | module Merge 4 | class CommonAncestors 5 | 6 | BOTH_PARENTS = Set.new([:parent1, :parent2]) 7 | 8 | def initialize(database, one, twos) 9 | @database = database 10 | @flags = Hash.new { |hash, oid| hash[oid] = Set.new } 11 | @queue = [] 12 | @results = [] 13 | 14 | insert_by_date(@queue, @database.load(one)) 15 | @flags[one].add(:parent1) 16 | 17 | twos.each do |two| 18 | insert_by_date(@queue, @database.load(two)) 19 | @flags[two].add(:parent2) 20 | end 21 | end 22 | 23 | def find 24 | process_queue until all_stale? 25 | @results.map(&:oid).reject { |oid| marked?(oid, :stale) } 26 | end 27 | 28 | def marked?(oid, flag) 29 | @flags[oid].include?(flag) 30 | end 31 | 32 | def counts 33 | ones, twos = 0, 0 34 | 35 | @flags.each do |oid, flags| 36 | next unless flags.size == 1 37 | ones += 1 if flags.include?(:parent1) 38 | twos += 1 if flags.include?(:parent2) 39 | end 40 | 41 | [ones, twos] 42 | end 43 | 44 | private 45 | 46 | def all_stale? 47 | @queue.all? { |commit| marked?(commit.oid, :stale) } 48 | end 49 | 50 | def process_queue 51 | commit = @queue.shift 52 | flags = @flags[commit.oid] 53 | 54 | if flags == BOTH_PARENTS 55 | flags.add(:result) 56 | insert_by_date(@results, commit) 57 | add_parents(commit, flags + [:stale]) 58 | else 59 | add_parents(commit, flags) 60 | end 61 | end 62 | 63 | def add_parents(commit, flags) 64 | commit.parents.each do |parent| 65 | next if @flags[parent].superset?(flags) 66 | 67 | @flags[parent].merge(flags) 68 | insert_by_date(@queue, @database.load(parent)) 69 | end 70 | end 71 | 72 | def insert_by_date(list, commit) 73 | index = list.find_index { |c| c.date < commit.date } 74 | list.insert(index || list.size, commit) 75 | end 76 | 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/diff/myers.rb: -------------------------------------------------------------------------------- 1 | module Diff 2 | class Myers 3 | 4 | def self.diff(a, b) 5 | Myers.new(a, b).diff 6 | end 7 | 8 | def initialize(a, b) 9 | @a, @b = a, b 10 | end 11 | 12 | def diff 13 | diff = [] 14 | 15 | backtrack do |prev_x, prev_y, x, y| 16 | a_line, b_line = @a[prev_x], @b[prev_y] 17 | 18 | if x == prev_x 19 | diff.push(Edit.new(:ins, nil, b_line)) 20 | elsif y == prev_y 21 | diff.push(Edit.new(:del, a_line, nil)) 22 | else 23 | diff.push(Edit.new(:eql, a_line, b_line)) 24 | end 25 | end 26 | 27 | diff.reverse 28 | end 29 | 30 | private 31 | 32 | def backtrack 33 | x, y = @a.size, @b.size 34 | 35 | shortest_edit.each_with_index.reverse_each do |v, d| 36 | k = x - y 37 | 38 | if k == -d or (k != d and v[k - 1] < v[k + 1]) 39 | prev_k = k + 1 40 | else 41 | prev_k = k - 1 42 | end 43 | 44 | prev_x = v[prev_k] 45 | prev_y = prev_x - prev_k 46 | 47 | while x > prev_x and y > prev_y 48 | yield x - 1, y - 1, x, y 49 | x, y = x - 1, y - 1 50 | end 51 | 52 | yield prev_x, prev_y, x, y if d > 0 53 | 54 | x, y = prev_x, prev_y 55 | end 56 | end 57 | 58 | def shortest_edit 59 | n, m = @a.size, @b.size 60 | max = n + m 61 | 62 | v = Array.new(2 * max + 1) 63 | v[1] = 0 64 | trace = [] 65 | 66 | (0 .. max).step do |d| 67 | trace.push(v.clone) 68 | 69 | (-d .. d).step(2) do |k| 70 | if k == -d or (k != d and v[k - 1] < v[k + 1]) 71 | x = v[k + 1] 72 | else 73 | x = v[k - 1] + 1 74 | end 75 | 76 | y = x - k 77 | 78 | while x < n and y < m and @a[x].text == @b[y].text 79 | x, y = x + 1, y + 1 80 | end 81 | 82 | v[k] = x 83 | 84 | return trace if x >= n and y >= m 85 | end 86 | end 87 | end 88 | 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/command/base.rb: -------------------------------------------------------------------------------- 1 | require "optparse" 2 | require "pathname" 3 | 4 | require_relative "../color" 5 | require_relative "../editor" 6 | require_relative "../pager" 7 | require_relative "../repository" 8 | 9 | module Command 10 | class Base 11 | 12 | attr_reader :status 13 | 14 | def initialize(dir, env, args, stdin, stdout, stderr) 15 | @dir = dir 16 | @env = env 17 | @args = args 18 | @stdin = stdin 19 | @stdout = stdout 20 | @stderr = stderr 21 | 22 | @isatty = @stdout.isatty 23 | end 24 | 25 | def execute 26 | parse_options 27 | catch(:exit) { run } 28 | 29 | if defined? @pager 30 | @stdout.close_write 31 | @pager.wait 32 | end 33 | end 34 | 35 | private 36 | 37 | def repo 38 | @repo ||= Repository.new(Pathname.new(@dir).join(".git")) 39 | end 40 | 41 | def expanded_pathname(path) 42 | Pathname.new(File.expand_path(path, @dir)) 43 | end 44 | 45 | def parse_options 46 | @options = {} 47 | @parser = OptionParser.new 48 | 49 | define_options 50 | @parser.parse!(@args) 51 | end 52 | 53 | def define_options 54 | end 55 | 56 | def setup_pager 57 | return if defined? @pager 58 | return unless @isatty 59 | 60 | @pager = Pager.new(@env, @stdout, @stderr) 61 | @stdout = @pager.input 62 | end 63 | 64 | def edit_file(path) 65 | Editor.edit(path, editor_command) do |editor| 66 | yield editor 67 | editor.close unless @isatty 68 | end 69 | end 70 | 71 | def editor_command 72 | core_editor = repo.config.get(["core", "editor"]) 73 | @env["GIT_EDITOR"] || core_editor || @env["VISUAL"] || @env["EDITOR"] 74 | end 75 | 76 | def fmt(style, string) 77 | @isatty ? Color.format(style, string) : string 78 | end 79 | 80 | def puts(string) 81 | @stdout.puts(string) 82 | rescue Errno::EPIPE 83 | exit 0 84 | end 85 | 86 | def exit(status = 0) 87 | @status = status 88 | throw :exit 89 | end 90 | 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/remotes.rb: -------------------------------------------------------------------------------- 1 | require_relative "./refs" 2 | require_relative "./remotes/refspec" 3 | require_relative "./remotes/remote" 4 | 5 | class Remotes 6 | DEFAULT_REMOTE = "origin" 7 | 8 | InvalidBranch = Class.new(StandardError) 9 | InvalidRemote = Class.new(StandardError) 10 | 11 | def initialize(config) 12 | @config = config 13 | end 14 | 15 | def add(name, url, branches = []) 16 | branches = ["*"] if branches.empty? 17 | @config.open_for_update 18 | 19 | if @config.get(["remote", name, "url"]) 20 | @config.save 21 | raise InvalidRemote, "remote #{ name } already exists." 22 | end 23 | 24 | @config.set(["remote", name, "url"], url) 25 | 26 | branches.each do |branch| 27 | source = Refs::HEADS_DIR.join(branch) 28 | target = Refs::REMOTES_DIR.join(name, branch) 29 | refspec = Refspec.new(source, target, true) 30 | 31 | @config.add(["remote", name, "fetch"], refspec.to_s) 32 | end 33 | 34 | @config.save 35 | end 36 | 37 | def remove(name) 38 | @config.open_for_update 39 | 40 | unless @config.remove_section(["remote", name]) 41 | raise InvalidRemote, "No such remote: #{ name }" 42 | end 43 | ensure 44 | @config.save 45 | end 46 | 47 | def list_remotes 48 | @config.open 49 | @config.subsections("remote") 50 | end 51 | 52 | def get(name) 53 | @config.open 54 | return nil unless @config.section?(["remote", name]) 55 | 56 | Remote.new(@config, name) 57 | end 58 | 59 | def get_upstream(branch) 60 | @config.open 61 | name = @config.get(["branch", branch, "remote"]) 62 | get(name)&.get_upstream(branch) 63 | end 64 | 65 | def set_upstream(branch, upstream) 66 | list_remotes.each do |name| 67 | ref = get(name).set_upstream(branch, upstream) 68 | return [name, ref] if ref 69 | end 70 | 71 | raise InvalidBranch, 72 | "Cannot setup tracking information; " + 73 | "starting point '#{ upstream }' is not a branch" 74 | end 75 | 76 | def unset_upstream(branch) 77 | @config.open_for_update 78 | @config.unset(["branch", branch, "remote"]) 79 | @config.unset(["branch", branch, "merge"]) 80 | @config.save 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/command/remote_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "command_helper" 3 | 4 | describe Command::Remote do 5 | include CommandHelper 6 | 7 | describe "adding a remote" do 8 | before do 9 | jit_cmd(*%w[remote add origin ssh://example.com/repo]) 10 | end 11 | 12 | it "fails to add an existing remote" do 13 | jit_cmd "remote", "add", "origin", "url" 14 | assert_status 128 15 | assert_stderr "fatal: remote origin already exists.\n" 16 | end 17 | 18 | it "lists the remote" do 19 | jit_cmd "remote" 20 | 21 | assert_stdout <<~REMOTES 22 | origin 23 | REMOTES 24 | end 25 | 26 | it "lists the remote with its URLs" do 27 | jit_cmd "remote", "--verbose" 28 | 29 | assert_stdout <<~REMOTES 30 | origin\tssh://example.com/repo (fetch) 31 | origin\tssh://example.com/repo (push) 32 | REMOTES 33 | end 34 | 35 | it "sets a catch-all fetch refspec" do 36 | jit_cmd "config", "--local", "--get-all", "remote.origin.fetch" 37 | 38 | assert_stdout <<~REFSPEC 39 | +refs/heads/*:refs/remotes/origin/* 40 | REFSPEC 41 | end 42 | end 43 | 44 | describe "adding a remote with tracking branches" do 45 | before do 46 | jit_cmd(*%w[remote add origin ssh://example.com/repo -t master -t topic]) 47 | end 48 | 49 | it "sets a fetch refspec for each branch" do 50 | jit_cmd "config", "--local", "--get-all", "remote.origin.fetch" 51 | 52 | assert_stdout <<~REFSPEC 53 | +refs/heads/master:refs/remotes/origin/master 54 | +refs/heads/topic:refs/remotes/origin/topic 55 | REFSPEC 56 | end 57 | end 58 | 59 | describe "removing a remote" do 60 | before do 61 | jit_cmd(*%w[remote add origin ssh://example.com/repo]) 62 | end 63 | 64 | it "removes the remote" do 65 | jit_cmd "remote", "remove", "origin" 66 | assert_status 0 67 | 68 | jit_cmd "remote" 69 | assert_stdout "" 70 | end 71 | 72 | it "fails to remove a missing remote" do 73 | jit_cmd "remote", "remove", "no-such" 74 | assert_status 128 75 | assert_stderr "fatal: No such remote: no-such\n" 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/pack/compressor.rb: -------------------------------------------------------------------------------- 1 | require_relative "./delta" 2 | require_relative "./window" 3 | 4 | module Pack 5 | class Compressor 6 | 7 | OBJECT_SIZE = 50..0x20000000 8 | MAX_DEPTH = 50 9 | WINDOW_SIZE = 8 10 | 11 | def initialize(database, progress) 12 | @database = database 13 | @window = Window.new(WINDOW_SIZE) 14 | @progress = progress 15 | @objects = [] 16 | end 17 | 18 | def add(entry) 19 | return unless OBJECT_SIZE.include?(entry.size) 20 | @objects.push(entry) 21 | end 22 | 23 | def build_deltas 24 | @progress&.start("Compressing objects", @objects.size) 25 | 26 | @objects.sort_by!(&:sort_key) 27 | 28 | @objects.reverse_each do |entry| 29 | build_delta(entry) 30 | @progress&.tick 31 | end 32 | @progress&.stop 33 | end 34 | 35 | private 36 | 37 | def build_delta(entry) 38 | object = @database.load_raw(entry.oid) 39 | target = @window.add(entry, object.data) 40 | 41 | @window.each { |source| try_delta(source, target) } 42 | end 43 | 44 | def try_delta(source, target) 45 | return unless source.type == target.type 46 | return unless source.depth < MAX_DEPTH 47 | 48 | max_size = max_size_heuristic(source, target) 49 | return unless compatible_sizes?(source, target, max_size) 50 | 51 | delta = Delta.new(source, target) 52 | size = target.entry.packed_size 53 | 54 | return if delta.size > max_size 55 | return if delta.size == size and delta.base.depth + 1 >= target.depth 56 | 57 | target.entry.assign_delta(delta) 58 | end 59 | 60 | def max_size_heuristic(source, target) 61 | if target.delta 62 | max_size = target.delta.size 63 | ref_depth = target.depth 64 | else 65 | max_size = target.size / 2 - 20 66 | ref_depth = 1 67 | end 68 | 69 | max_size * (MAX_DEPTH - source.depth) / (MAX_DEPTH + 1 - ref_depth) 70 | end 71 | 72 | def compatible_sizes?(source, target, max_size) 73 | size_diff = [target.size - source.size, 0].max 74 | 75 | return false if max_size == 0 76 | return false if size_diff >= max_size 77 | return false if target.size < source.size / 32 78 | 79 | true 80 | end 81 | 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/command/commit.rb: -------------------------------------------------------------------------------- 1 | require "pathname" 2 | require_relative "./base" 3 | require_relative "./shared/write_commit" 4 | require_relative "../revision" 5 | 6 | module Command 7 | class Commit < Base 8 | 9 | include WriteCommit 10 | 11 | COMMIT_NOTES = <<~MSG 12 | Please enter the commit message for your changes. Lines starting 13 | with '#' will be ignored, and an empty message aborts the commit. 14 | MSG 15 | 16 | def define_options 17 | define_write_commit_options 18 | 19 | @parser.on("--amend") { @options[:amend] = true } 20 | 21 | @parser.on "-C ", "--reuse-message=" do |commit| 22 | @options[:reuse] = commit 23 | @options[:edit] = false 24 | end 25 | 26 | @parser.on "-c ", "--reedit-message=" do |commit| 27 | @options[:reuse] = commit 28 | @options[:edit] = true 29 | end 30 | end 31 | 32 | def run 33 | repo.index.load 34 | 35 | handle_amend if @options[:amend] 36 | 37 | merge_type = pending_commit.merge_type 38 | resume_merge(merge_type) if merge_type 39 | 40 | parent = repo.refs.read_head 41 | message = compose_message(read_message || reused_message) 42 | commit = write_commit([*parent], message) 43 | 44 | print_commit(commit) 45 | 46 | exit 0 47 | end 48 | 49 | private 50 | 51 | def compose_message(message) 52 | edit_file(commit_message_path) do |editor| 53 | editor.puts(message || "") 54 | editor.puts("") 55 | editor.note(COMMIT_NOTES) 56 | 57 | editor.close unless @options[:edit] 58 | end 59 | end 60 | 61 | def reused_message 62 | return nil unless @options.has_key?(:reuse) 63 | 64 | revision = Revision.new(repo, @options[:reuse]) 65 | commit = repo.database.load(revision.resolve) 66 | 67 | commit.message 68 | end 69 | 70 | def handle_amend 71 | old = repo.database.load(repo.refs.read_head) 72 | tree = write_tree 73 | 74 | message = compose_message(old.message) 75 | committer = current_author 76 | 77 | new = Database::Commit.new(old.parents, tree.oid, old.author, committer, message) 78 | repo.database.store(new) 79 | repo.refs.update_head(new.oid) 80 | 81 | print_commit(new) 82 | exit 0 83 | end 84 | 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/pack/reader.rb: -------------------------------------------------------------------------------- 1 | require "zlib" 2 | 3 | require_relative "./expander" 4 | require_relative "./numbers" 5 | 6 | module Pack 7 | class Reader 8 | 9 | attr_reader :count 10 | 11 | def initialize(input) 12 | @input = input 13 | end 14 | 15 | def read_header 16 | data = @input.read(HEADER_SIZE) 17 | signature, version, @count = data.unpack(HEADER_FORMAT) 18 | 19 | unless signature == SIGNATURE 20 | raise InvalidPack, "bad pack signature: #{ signature }" 21 | end 22 | 23 | unless version == VERSION 24 | raise InvalidPack, "unsupported pack version: #{ version }" 25 | end 26 | end 27 | 28 | def read_record 29 | type, _ = read_record_header 30 | 31 | case type 32 | when COMMIT, TREE, BLOB 33 | Record.new(TYPE_CODES.key(type), read_zlib_stream) 34 | when OFS_DELTA 35 | read_ofs_delta 36 | when REF_DELTA 37 | read_ref_delta 38 | end 39 | end 40 | 41 | def read_info 42 | type, size = read_record_header 43 | 44 | case type 45 | when COMMIT, TREE, BLOB 46 | Record.new(TYPE_CODES.key(type), size) 47 | 48 | when OFS_DELTA 49 | delta = read_ofs_delta 50 | size = Expander.new(delta.delta_data).target_size 51 | 52 | OfsDelta.new(delta.base_ofs, size) 53 | 54 | when REF_DELTA 55 | delta = read_ref_delta 56 | size = Expander.new(delta.delta_data).target_size 57 | 58 | RefDelta.new(delta.base_oid, size) 59 | end 60 | end 61 | 62 | private 63 | 64 | def read_record_header 65 | byte, size = Numbers::VarIntLE.read(@input, 4) 66 | type = (byte >> 4) & 0x7 67 | 68 | [type, size] 69 | end 70 | 71 | def read_ofs_delta 72 | offset = Numbers::VarIntBE.read(@input) 73 | OfsDelta.new(offset, read_zlib_stream) 74 | end 75 | 76 | def read_ref_delta 77 | base_oid = @input.read(20).unpack("H40").first 78 | RefDelta.new(base_oid, read_zlib_stream) 79 | end 80 | 81 | def read_zlib_stream 82 | stream = Zlib::Inflate.new 83 | string = "" 84 | total = 0 85 | 86 | until stream.finished? 87 | data = @input.read_nonblock(256) 88 | total += data.bytesize 89 | 90 | string.concat(stream.inflate(data)) 91 | end 92 | @input.seek(stream.total_in - total, IO::SEEK_CUR) 93 | 94 | string 95 | end 96 | 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/pack/index.rb: -------------------------------------------------------------------------------- 1 | module Pack 2 | class Index 3 | 4 | HEADER_SIZE = 8 5 | FANOUT_SIZE = 1024 6 | 7 | OID_LAYER = 2 8 | CRC_LAYER = 3 9 | OFS_LAYER = 4 10 | EXT_LAYER = 5 11 | 12 | SIZES = { 13 | OID_LAYER => 20, 14 | CRC_LAYER => 4, 15 | OFS_LAYER => 4, 16 | EXT_LAYER => 8 17 | } 18 | 19 | def initialize(input) 20 | @input = input 21 | load_fanout_table 22 | end 23 | 24 | def oid_offset(oid) 25 | pos = oid_position(oid) 26 | return nil if pos < 0 27 | 28 | offset = read_int32(OFS_LAYER, pos) 29 | 30 | return offset if offset < IDX_MAX_OFFSET 31 | 32 | pos = offset & (IDX_MAX_OFFSET - 1) 33 | @input.seek(offset_for(EXT_LAYER, pos)) 34 | @input.read(8).unpack("Q>").first 35 | end 36 | 37 | def prefix_match(name) 38 | pos = oid_position(name) 39 | return [name] unless pos < 0 40 | 41 | @input.seek(offset_for(OID_LAYER, -1 - pos)) 42 | oids = [] 43 | 44 | loop do 45 | oid = @input.read(20).unpack("H40").first 46 | return oids unless oid.start_with?(name) 47 | oids.push(oid) 48 | end 49 | end 50 | 51 | private 52 | 53 | def load_fanout_table 54 | @input.seek(HEADER_SIZE) 55 | @fanout = @input.read(FANOUT_SIZE).unpack("N256") 56 | end 57 | 58 | def oid_position(oid) 59 | prefix = oid[0..1].to_i(16) 60 | packed = [oid].pack("H40") 61 | 62 | low = (prefix == 0) ? 0 : @fanout[prefix - 1] 63 | high = @fanout[prefix] - 1 64 | 65 | binary_search(packed, low, high) 66 | end 67 | 68 | def read_int32(layer, pos) 69 | @input.seek(offset_for(layer, pos)) 70 | @input.read(4).unpack("N").first 71 | end 72 | 73 | def offset_for(layer, pos) 74 | offset = HEADER_SIZE + FANOUT_SIZE 75 | count = @fanout.last 76 | 77 | SIZES.each { |n, size| offset += size * count if n < layer } 78 | 79 | offset + pos * SIZES[layer] 80 | end 81 | 82 | def binary_search(target, low, high) 83 | while low <= high 84 | mid = (low + high) / 2 85 | 86 | @input.seek(offset_for(OID_LAYER, mid)) 87 | oid = @input.read(20) 88 | 89 | case oid <=> target 90 | when -1 then low = mid + 1 91 | when 0 then return mid 92 | when 1 then high = mid - 1 93 | end 94 | end 95 | 96 | -1 - low 97 | end 98 | 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/pack/writer.rb: -------------------------------------------------------------------------------- 1 | require "digest/sha1" 2 | require "zlib" 3 | 4 | require_relative "./compressor" 5 | require_relative "./entry" 6 | require_relative "./numbers" 7 | 8 | module Pack 9 | class Writer 10 | 11 | def initialize(output, database, options = {}) 12 | @output = output 13 | @database = database 14 | @digest = Digest::SHA1.new 15 | @offset = 0 16 | 17 | @compression = options.fetch(:compression, Zlib::DEFAULT_COMPRESSION) 18 | @allow_ofs = options[:allow_ofs] 19 | @progress = options[:progress] 20 | end 21 | 22 | def write_objects(rev_list) 23 | prepare_pack_list(rev_list) 24 | compress_objects 25 | write_header 26 | write_entries 27 | @output.write(@digest.digest) 28 | end 29 | 30 | private 31 | 32 | def write(data) 33 | @output.write(data) 34 | @digest.update(data) 35 | @offset += data.bytesize 36 | end 37 | 38 | def prepare_pack_list(rev_list) 39 | @pack_list = [] 40 | @progress&.start("Counting objects") 41 | 42 | rev_list.each do |object, path| 43 | add_to_pack_list(object, path) 44 | @progress&.tick 45 | end 46 | @progress&.stop 47 | end 48 | 49 | def add_to_pack_list(object, path) 50 | info = @database.load_info(object.oid) 51 | @pack_list.push(Entry.new(object.oid, info, path, @allow_ofs)) 52 | end 53 | 54 | def compress_objects 55 | compressor = Compressor.new(@database, @progress) 56 | @pack_list.each { |entry| compressor.add(entry) } 57 | compressor.build_deltas 58 | end 59 | 60 | def write_header 61 | header = [SIGNATURE, VERSION, @pack_list.size].pack(HEADER_FORMAT) 62 | write(header) 63 | end 64 | 65 | def write_entries 66 | count = @pack_list.size 67 | @progress&.start("Writing objects", count) unless @output == STDOUT 68 | 69 | @pack_list.each { |entry| write_entry(entry) } 70 | @progress&.stop 71 | end 72 | 73 | def write_entry(entry) 74 | write_entry(entry.delta.base) if entry.delta 75 | 76 | return if entry.offset 77 | entry.offset = @offset 78 | 79 | object = entry.delta || @database.load_raw(entry.oid) 80 | 81 | header = Numbers::VarIntLE.write(entry.packed_size, 4) 82 | header[0] |= entry.packed_type << 4 83 | 84 | write(header.pack("C*")) 85 | write(entry.delta_prefix) 86 | write(Zlib::Deflate.deflate(object.data, @compression)) 87 | 88 | @progress&.tick(@offset) 89 | end 90 | 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/index/entry.rb: -------------------------------------------------------------------------------- 1 | require "pathname" 2 | 3 | class Index 4 | ENTRY_FORMAT = "N10H40nZ*" 5 | ENTRY_BLOCK = 8 6 | ENTRY_MIN_SIZE = 64 7 | 8 | REGULAR_MODE = 0100644 9 | EXECUTABLE_MODE = 0100755 10 | MAX_PATH_SIZE = 0xfff 11 | 12 | entry_fields = [ 13 | :ctime, :ctime_nsec, 14 | :mtime, :mtime_nsec, 15 | :dev, :ino, :mode, :uid, :gid, :size, 16 | :oid, :flags, :path 17 | ] 18 | 19 | Entry = Struct.new(*entry_fields) do 20 | def self.create(pathname, oid, stat) 21 | path = pathname.to_s 22 | mode = Entry.mode_for_stat(stat) 23 | flags = [path.bytesize, MAX_PATH_SIZE].min 24 | 25 | Entry.new( 26 | stat.ctime.to_i, stat.ctime.nsec, 27 | stat.mtime.to_i, stat.mtime.nsec, 28 | stat.dev, stat.ino, mode, stat.uid, stat.gid, stat.size, 29 | oid, flags, path) 30 | end 31 | 32 | def self.create_from_db(pathname, item, n) 33 | path = pathname.to_s 34 | flags = (n << 12) | [path.bytesize, MAX_PATH_SIZE].min 35 | 36 | Entry.new(0, 0, 0, 0, 0, 0, item.mode, 0, 0, 0, item.oid, flags, path) 37 | end 38 | 39 | def self.mode_for_stat(stat) 40 | stat.executable? ? EXECUTABLE_MODE : REGULAR_MODE 41 | end 42 | 43 | def self.parse(data) 44 | Entry.new(*data.unpack(ENTRY_FORMAT)) 45 | end 46 | 47 | def key 48 | [path, stage] 49 | end 50 | 51 | def stage 52 | (flags >> 12) & 0x3 53 | end 54 | 55 | def parent_directories 56 | Pathname.new(path).descend.to_a[0..-2] 57 | end 58 | 59 | def basename 60 | Pathname.new(path).basename 61 | end 62 | 63 | def update_stat(stat) 64 | self.ctime = stat.ctime.to_i 65 | self.ctime_nsec = stat.ctime.nsec 66 | self.mtime = stat.mtime.to_i 67 | self.mtime_nsec = stat.mtime.nsec 68 | self.dev = stat.dev 69 | self.ino = stat.ino 70 | self.mode = Entry.mode_for_stat(stat) 71 | self.uid = stat.uid 72 | self.gid = stat.gid 73 | self.size = stat.size 74 | end 75 | 76 | def stat_match?(stat) 77 | mode == Entry.mode_for_stat(stat) and (size == 0 or size == stat.size) 78 | end 79 | 80 | def times_match?(stat) 81 | ctime == stat.ctime.to_i and ctime_nsec == stat.ctime.nsec and 82 | mtime == stat.mtime.to_i and mtime_nsec == stat.mtime.nsec 83 | end 84 | 85 | def to_s 86 | string = to_a.pack(ENTRY_FORMAT) 87 | string.concat("\0") until string.bytesize % ENTRY_BLOCK == 0 88 | string 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/command/shared/remote_client.rb: -------------------------------------------------------------------------------- 1 | require "open3" 2 | require "shellwords" 3 | require "uri" 4 | 5 | require_relative "../../remotes/protocol" 6 | 7 | module Command 8 | module RemoteClient 9 | 10 | REF_LINE = /^([0-9a-f]+) (.*)$/ 11 | ZERO_OID = "0" * 40 12 | 13 | def start_agent(name, program, url, capabilities = []) 14 | argv = build_agent_command(program, url) 15 | input, output, _ = Open3.popen2(Shellwords.shelljoin(argv)) 16 | @conn = Remotes::Protocol.new(name, output, input, capabilities) 17 | end 18 | 19 | def build_agent_command(program, url) 20 | uri = URI.parse(url) 21 | argv = Shellwords.shellsplit(program) + [uri.path] 22 | 23 | case uri.scheme 24 | when "file" then argv 25 | when "ssh" then ssh_command(uri, argv) 26 | end 27 | end 28 | 29 | def ssh_command(uri, argv) 30 | ssh = ["ssh", uri.host] 31 | ssh += ["-p", uri.port.to_s] if uri.port 32 | ssh += ["-l", uri.user] if uri.user 33 | 34 | ssh + [Shellwords.shelljoin(argv)] 35 | end 36 | 37 | def recv_references 38 | @remote_refs = {} 39 | 40 | @conn.recv_until(nil) do |line| 41 | oid, ref = REF_LINE.match(line).captures 42 | @remote_refs[ref] = oid.downcase unless oid == ZERO_OID 43 | end 44 | end 45 | 46 | def report_ref_update(ref_names, error, old_oid = nil, new_oid = nil, is_ff = false) 47 | return show_ref_update("!", "[rejected]", ref_names, error) if error 48 | return if old_oid == new_oid 49 | 50 | if old_oid == nil 51 | show_ref_update("*", "[new branch]", ref_names) 52 | elsif new_oid == nil 53 | show_ref_update("-", "[deleted]", ref_names) 54 | else 55 | report_range_update(ref_names, old_oid, new_oid, is_ff) 56 | end 57 | end 58 | 59 | def report_range_update(ref_names, old_oid, new_oid, is_ff) 60 | old_oid = repo.database.short_oid(old_oid) 61 | new_oid = repo.database.short_oid(new_oid) 62 | 63 | if is_ff 64 | revisions = "#{ old_oid }..#{ new_oid }" 65 | show_ref_update(" ", revisions, ref_names) 66 | else 67 | revisions = "#{ old_oid }...#{ new_oid }" 68 | show_ref_update("+", revisions, ref_names, "forced update") 69 | end 70 | end 71 | 72 | def show_ref_update(flag, summary, ref_names, reason = nil) 73 | names = ref_names.compact.map { |name| repo.refs.short_name(name) } 74 | 75 | message = " #{ flag } #{ summary } #{ names.join(" -> ") }" 76 | message.concat(" (#{ reason })") if reason 77 | 78 | @stderr.puts message 79 | end 80 | 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /test/pack/delta_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "fileutils" 3 | require "pathname" 4 | require "securerandom" 5 | require "set" 6 | 7 | require "database" 8 | require "index" 9 | require "pack" 10 | require "pack/xdelta" 11 | 12 | describe Pack do 13 | blob_text_1 = SecureRandom.hex(256) 14 | blob_text_2 = blob_text_1 + "new content" 15 | 16 | describe Pack::XDelta do 17 | it "compresses a blob" do 18 | index = Pack::XDelta.create_index(blob_text_2) 19 | delta = index.compress(blob_text_1).join("") 20 | 21 | assert_equal 2, delta.bytesize 22 | end 23 | end 24 | 25 | def create_db(path) 26 | path = File.expand_path(path, __FILE__) 27 | FileUtils.mkdir_p(path) 28 | @db_paths.add(path) 29 | Database.new(Pathname.new(path)) 30 | end 31 | 32 | tests = { 33 | "unpacking objects" => Pack::Unpacker, 34 | "indexing the pack" => Pack::Indexer 35 | } 36 | 37 | [false, true].each do |allow_ofs| 38 | describe "with ofs-delta = #{ allow_ofs }" do 39 | 40 | tests.each do |name, processor| 41 | describe name do 42 | 43 | before do 44 | @db_paths = Set.new 45 | source = create_db("../db-source") 46 | target = create_db("../db-target") 47 | 48 | @blobs = [blob_text_1, blob_text_2].map do |data| 49 | blob = Database::Blob.new(data) 50 | source.store(blob) 51 | Database::Entry.new(blob.oid, Index::REGULAR_MODE) 52 | end 53 | 54 | input, output = IO.pipe 55 | 56 | writer = Pack::Writer.new(output, source, :allow_ofs => allow_ofs) 57 | writer.write_objects(@blobs) 58 | 59 | stream = Pack::Stream.new(input) 60 | reader = Pack::Reader.new(stream) 61 | reader.read_header 62 | 63 | unpacker = processor.new(target, reader, stream, nil) 64 | unpacker.process_pack 65 | 66 | @db = create_db("../db-target") 67 | end 68 | 69 | after do 70 | @db_paths.each { |path| FileUtils.rm_rf(path) } 71 | end 72 | 73 | it "stores the blobs in the target database" do 74 | blobs = @blobs.map { |b| @db.load(b.oid) } 75 | 76 | assert_equal blob_text_1, blobs[0].data 77 | assert_equal blob_text_2, blobs[1].data 78 | end 79 | 80 | it "can load the info for each blob" do 81 | infos = @blobs.map { |b| @db.load_info(b.oid) } 82 | 83 | assert_equal Database::Raw.new("blob", 512), infos[0] 84 | assert_equal Database::Raw.new("blob", 523), infos[1] 85 | end 86 | end 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /test/revision_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "revision" 3 | 4 | describe Revision do 5 | describe "parse" do 6 | def assert_parse(expression, tree) 7 | assert_equal tree, Revision.parse(expression) 8 | end 9 | 10 | it "parses HEAD" do 11 | assert_parse "HEAD", 12 | Revision::Ref.new("HEAD") 13 | end 14 | 15 | it "parses @" do 16 | assert_parse "@", 17 | Revision::Ref.new("HEAD") 18 | end 19 | 20 | it "parses a branch name" do 21 | assert_parse "master", 22 | Revision::Ref.new("master") 23 | end 24 | 25 | it "parses an object ID" do 26 | assert_parse "3803cb6dc4ab0a852c6762394397dc44405b5ae4", 27 | Revision::Ref.new("3803cb6dc4ab0a852c6762394397dc44405b5ae4") 28 | end 29 | 30 | it "parses a parent ref" do 31 | assert_parse "HEAD^", 32 | Revision::Parent.new(Revision::Ref.new("HEAD"), 1) 33 | end 34 | 35 | it "parses a chain of parent refs" do 36 | assert_parse "master^^^", 37 | Revision::Parent.new( 38 | Revision::Parent.new( 39 | Revision::Parent.new( 40 | Revision::Ref.new("master"), 41 | 1), 42 | 1), 43 | 1) 44 | end 45 | 46 | it "parses a parent ref with a number" do 47 | assert_parse "@^2", 48 | Revision::Parent.new(Revision::Ref.new("HEAD"), 2) 49 | end 50 | 51 | it "parses an ancestor ref" do 52 | assert_parse "@~3", 53 | Revision::Ancestor.new( 54 | Revision::Ref.new("HEAD"), 55 | 3) 56 | end 57 | 58 | it "parses a chain of parents and ancestors" do 59 | assert_parse "@~2^^~3", 60 | Revision::Ancestor.new( 61 | Revision::Parent.new( 62 | Revision::Parent.new( 63 | Revision::Ancestor.new( 64 | Revision::Ref.new("HEAD"), 65 | 2), 66 | 1), 67 | 1), 68 | 3) 69 | end 70 | 71 | it "parses an upstream" do 72 | assert_parse "master@{uPsTrEaM}", 73 | Revision::Upstream.new(Revision::Ref.new("master")) 74 | end 75 | 76 | it "parses a short-hand upstream" do 77 | assert_parse "master@{u}", 78 | Revision::Upstream.new(Revision::Ref.new("master")) 79 | end 80 | 81 | it "parses an upstream with no branch" do 82 | assert_parse "@{u}", 83 | Revision::Upstream.new(Revision::Ref.new("HEAD")) 84 | end 85 | 86 | it "parses an upstream with some ancestor operators" do 87 | assert_parse "master@{u}^~3", 88 | Revision::Ancestor.new( 89 | Revision::Parent.new( 90 | Revision::Upstream.new(Revision::Ref.new("master")), 91 | 1), 92 | 3) 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/repository/status.rb: -------------------------------------------------------------------------------- 1 | require "set" 2 | 3 | require_relative "./inspector" 4 | require_relative "../sorted_hash" 5 | 6 | class Repository 7 | class Status 8 | 9 | attr_reader :changed, 10 | :stats, 11 | :head_tree, 12 | :index_changes, 13 | :conflicts, 14 | :workspace_changes, 15 | :untracked_files 16 | 17 | def initialize(repository, commit_oid = nil) 18 | @repo = repository 19 | @stats = {} 20 | 21 | @inspector = Inspector.new(@repo) 22 | 23 | @changed = SortedSet.new 24 | @index_changes = SortedHash.new 25 | @conflicts = SortedHash.new 26 | @workspace_changes = SortedHash.new 27 | @untracked_files = SortedSet.new 28 | 29 | commit_oid ||= @repo.refs.read_head 30 | @head_tree = @repo.database.load_tree_list(commit_oid) 31 | 32 | scan_workspace 33 | check_index_entries 34 | collect_deleted_head_files 35 | end 36 | 37 | private 38 | 39 | def record_change(path, set, type) 40 | @changed.add(path) 41 | set[path] = type 42 | end 43 | 44 | def scan_workspace(prefix = nil) 45 | @repo.workspace.list_dir(prefix).each do |path, stat| 46 | if @repo.index.tracked?(path) 47 | @stats[path] = stat if stat.file? 48 | scan_workspace(path) if stat.directory? 49 | elsif @inspector.trackable_file?(path, stat) 50 | path += File::SEPARATOR if stat.directory? 51 | @untracked_files.add(path) 52 | end 53 | end 54 | end 55 | 56 | def check_index_entries 57 | @repo.index.each_entry do |entry| 58 | if entry.stage == 0 59 | check_index_against_workspace(entry) 60 | check_index_against_head_tree(entry) 61 | else 62 | @changed.add(entry.path) 63 | @conflicts[entry.path] ||= [] 64 | @conflicts[entry.path].push(entry.stage) 65 | end 66 | end 67 | end 68 | 69 | def check_index_against_workspace(entry) 70 | stat = @stats[entry.path] 71 | status = @inspector.compare_index_to_workspace(entry, stat) 72 | 73 | if status 74 | record_change(entry.path, @workspace_changes, status) 75 | else 76 | @repo.index.update_entry_stat(entry, stat) 77 | end 78 | end 79 | 80 | def check_index_against_head_tree(entry) 81 | item = @head_tree[entry.path] 82 | status = @inspector.compare_tree_to_index(item, entry) 83 | 84 | if status 85 | record_change(entry.path, @index_changes, status) 86 | end 87 | end 88 | 89 | def collect_deleted_head_files 90 | @head_tree.each_key do |path| 91 | unless @repo.index.tracked_file?(path) 92 | record_change(path, @index_changes, :deleted) 93 | end 94 | end 95 | end 96 | 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/command/add_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "command_helper" 3 | 4 | describe Command::Add do 5 | include CommandHelper 6 | 7 | def assert_index(expected) 8 | repo.index.load 9 | actual = repo.index.each_entry.map { |entry| [entry.mode, entry.path] } 10 | assert_equal expected, actual 11 | end 12 | 13 | it "adds a regular file to the index" do 14 | write_file "hello.txt", "hello" 15 | 16 | jit_cmd "add", "hello.txt" 17 | 18 | assert_index [[0100644, "hello.txt"]] 19 | end 20 | 21 | it "adds an executable file to the index" do 22 | write_file "hello.txt", "hello" 23 | make_executable "hello.txt" 24 | 25 | jit_cmd "add", "hello.txt" 26 | 27 | assert_index [[0100755, "hello.txt"]] 28 | end 29 | 30 | it "adds multiple files to the index" do 31 | write_file "hello.txt", "hello" 32 | write_file "world.txt", "world" 33 | 34 | jit_cmd "add", "hello.txt", "world.txt" 35 | 36 | assert_index [[0100644, "hello.txt"], [0100644, "world.txt"]] 37 | end 38 | 39 | it "incrementally adds files to the index" do 40 | write_file "hello.txt", "hello" 41 | write_file "world.txt", "world" 42 | 43 | jit_cmd "add", "world.txt" 44 | 45 | assert_index [[0100644, "world.txt"]] 46 | 47 | jit_cmd "add", "hello.txt" 48 | 49 | assert_index [[0100644, "hello.txt"], [0100644, "world.txt"]] 50 | end 51 | 52 | it "adds a directory to the index" do 53 | write_file "a-dir/nested.txt", "content" 54 | 55 | jit_cmd "add", "a-dir" 56 | 57 | assert_index [[0100644, "a-dir/nested.txt"]] 58 | end 59 | 60 | it "adds the repository root to the index" do 61 | write_file "a/b/c/file.txt", "content" 62 | 63 | jit_cmd "add", "." 64 | 65 | assert_index [[0100644, "a/b/c/file.txt"]] 66 | end 67 | 68 | it "is silent on success" do 69 | write_file "hello.txt", "hello" 70 | 71 | jit_cmd "add", "hello.txt" 72 | 73 | assert_status 0 74 | assert_stdout "" 75 | assert_stderr "" 76 | end 77 | 78 | it "fails for non-existent files" do 79 | jit_cmd "add", "no-such-file" 80 | 81 | assert_stderr <<~ERROR 82 | fatal: pathspec 'no-such-file' did not match any files 83 | ERROR 84 | assert_status 128 85 | assert_index [] 86 | end 87 | 88 | it "fails for unreadable files" do 89 | write_file "secret.txt", "" 90 | make_unreadable "secret.txt" 91 | 92 | jit_cmd "add", "secret.txt" 93 | 94 | assert_stderr <<~ERROR 95 | error: open('secret.txt'): Permission denied 96 | fatal: adding files failed 97 | ERROR 98 | assert_status 128 99 | assert_index [] 100 | end 101 | 102 | it "fails if the index is locked" do 103 | write_file "file.txt", "" 104 | write_file ".git/index.lock", "" 105 | 106 | jit_cmd "add", "file.txt" 107 | 108 | assert_status 128 109 | assert_index [] 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/database.rb: -------------------------------------------------------------------------------- 1 | require "digest/sha1" 2 | require "forwardable" 3 | require "pathname" 4 | require "strscan" 5 | 6 | require_relative "./database/author" 7 | require_relative "./database/blob" 8 | require_relative "./database/commit" 9 | require_relative "./database/entry" 10 | require_relative "./database/tree" 11 | require_relative "./database/tree_diff" 12 | 13 | require_relative "./database/backends" 14 | require_relative "./path_filter" 15 | 16 | class Database 17 | TYPES = { 18 | "blob" => Blob, 19 | "tree" => Tree, 20 | "commit" => Commit 21 | } 22 | 23 | Raw = Struct.new(:type, :size, :data) 24 | 25 | extend Forwardable 26 | def_delegators :@backend, :has?, :load_info, :load_raw, 27 | :prefix_match, :pack_path, :reload 28 | 29 | def initialize(pathname) 30 | @objects = {} 31 | @backend = Backends.new(pathname) 32 | end 33 | 34 | def store(object) 35 | content = serialize_object(object) 36 | object.oid = hash_content(content) 37 | 38 | @backend.write_object(object.oid, content) 39 | end 40 | 41 | def hash_object(object) 42 | hash_content(serialize_object(object)) 43 | end 44 | 45 | def load(oid) 46 | @objects[oid] ||= read_object(oid) 47 | end 48 | 49 | def load_tree_entry(oid, pathname) 50 | commit = load(oid) 51 | root = Database::Entry.new(commit.tree, Tree::TREE_MODE) 52 | 53 | return root unless pathname 54 | 55 | pathname.each_filename.reduce(root) do |entry, name| 56 | entry ? load(entry.oid).entries[name] : nil 57 | end 58 | end 59 | 60 | def load_tree_list(oid, pathname = nil) 61 | return {} unless oid 62 | 63 | entry = load_tree_entry(oid, pathname) 64 | list = {} 65 | 66 | build_list(list, entry, pathname || Pathname.new("")) 67 | list 68 | end 69 | 70 | def build_list(list, entry, prefix) 71 | return unless entry 72 | return list[prefix.to_s] = entry unless entry.tree? 73 | 74 | load(entry.oid).entries.each do |name, item| 75 | build_list(list, item, prefix.join(name)) 76 | end 77 | end 78 | 79 | def tree_entry(oid) 80 | Entry.new(oid, Tree::TREE_MODE) 81 | end 82 | 83 | def short_oid(oid) 84 | oid[0..6] 85 | end 86 | 87 | def tree_diff(a, b, filter = PathFilter.new) 88 | diff = TreeDiff.new(self) 89 | diff.compare_oids(a, b, filter) 90 | diff.changes 91 | end 92 | 93 | private 94 | 95 | def serialize_object(object) 96 | string = object.to_s.force_encoding(Encoding::ASCII_8BIT) 97 | "#{ object.type } #{ string.bytesize }\0#{ string }" 98 | end 99 | 100 | def hash_content(string) 101 | Digest::SHA1.hexdigest(string) 102 | end 103 | 104 | def read_object(oid) 105 | raw = load_raw(oid) 106 | scanner = StringScanner.new(raw.data) 107 | 108 | object = TYPES[raw.type].parse(scanner) 109 | object.oid = oid 110 | 111 | object 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/command/receive_pack.rb: -------------------------------------------------------------------------------- 1 | require_relative "./base" 2 | require_relative "./shared/fast_forward" 3 | require_relative "./shared/receive_objects" 4 | require_relative "./shared/remote_agent" 5 | 6 | module Command 7 | class ReceivePack < Base 8 | 9 | include FastForward 10 | include ReceiveObjects 11 | include RemoteAgent 12 | 13 | CAPABILITIES = ["no-thin", "report-status", "delete-refs", "ofs-delta"] 14 | 15 | def run 16 | accept_client("receive-pack", CAPABILITIES) 17 | 18 | send_references 19 | recv_update_requests 20 | recv_objects 21 | update_refs 22 | 23 | exit 0 24 | end 25 | 26 | private 27 | 28 | def recv_update_requests 29 | @requests = {} 30 | 31 | @conn.recv_until(nil) do |line| 32 | old_oid, new_oid, ref = line.split(/ +/) 33 | @requests[ref] = [old_oid, new_oid].map { |oid| zero_to_nil(oid) } 34 | end 35 | end 36 | 37 | def zero_to_nil(oid) 38 | oid == ZERO_OID ? nil : oid 39 | end 40 | 41 | def recv_objects 42 | @unpack_error = nil 43 | unpack_limit = repo.config.get(["receive", "unpackLimit"]) 44 | recv_packed_objects(unpack_limit) if @requests.values.any?(&:last) 45 | report_status("unpack ok") 46 | rescue => error 47 | @unpack_error = error 48 | report_status("unpack #{ error.message }") 49 | end 50 | 51 | def update_refs 52 | @requests.each { |ref, (old, new)| update_ref(ref, old, new) } 53 | report_status(nil) 54 | end 55 | 56 | def update_ref(ref, old_oid, new_oid) 57 | return report_status("ng #{ ref } unpacker error") if @unpack_error 58 | 59 | validate_update(ref, old_oid, new_oid) 60 | repo.refs.compare_and_swap(ref, old_oid, new_oid) 61 | report_status("ok #{ ref }") 62 | rescue => error 63 | report_status("ng #{ ref } #{ error.message }") 64 | end 65 | 66 | def validate_update(ref, old_oid, new_oid) 67 | raise "funny refname" unless Revision.valid_ref?(ref) 68 | raise "missing necessary objects" if new_oid and not repo.database.has?(new_oid) 69 | 70 | if repo.config.get(["receive", "denyDeletes"]) 71 | raise "deletion prohibited" unless new_oid 72 | end 73 | 74 | if repo.config.get(["receive", "denyNonFastForwards"]) 75 | raise "non-fast-forward" if fast_forward_error(old_oid, new_oid) 76 | end 77 | 78 | return unless repo.config.get(["core", "bare"]) == false and 79 | repo.refs.current_ref.path == ref 80 | 81 | unless repo.config.get(["receive", "denyCurrentBranch"]) == false 82 | raise "branch is currently checked out" if new_oid 83 | end 84 | 85 | unless repo.config.get(["receive", "denyDeleteCurrent"]) == false 86 | raise "deletion of the current branch prohibited" unless new_oid 87 | end 88 | end 89 | 90 | def report_status(line) 91 | @conn.send_packet(line) if @conn.capable?("report-status") 92 | end 93 | 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/pack/xdelta.rb: -------------------------------------------------------------------------------- 1 | module Pack 2 | class XDelta 3 | 4 | BLOCK_SIZE = 16 5 | 6 | def self.create_index(source) 7 | blocks = source.bytesize / BLOCK_SIZE 8 | index = {} 9 | 10 | (0 ... blocks).each do |i| 11 | offset = i * BLOCK_SIZE 12 | slice = source.byteslice(offset, BLOCK_SIZE) 13 | 14 | index[slice] ||= [] 15 | index[slice].push(offset) 16 | end 17 | 18 | XDelta.new(source, index) 19 | end 20 | 21 | def initialize(source, index) 22 | @source = source 23 | @index = index 24 | end 25 | 26 | def compress(target) 27 | @target = target 28 | @offset = 0 29 | @insert = [] 30 | @ops = [] 31 | 32 | generate_ops while @offset < @target.bytesize 33 | flush_insert 34 | 35 | @ops 36 | end 37 | 38 | private 39 | 40 | def generate_ops 41 | m_offset, m_size = longest_match 42 | return push_insert if m_size == 0 43 | 44 | m_offset, m_size = expand_match(m_offset, m_size) 45 | 46 | flush_insert 47 | @ops.push(Delta::Copy.new(m_offset, m_size)) 48 | end 49 | 50 | def longest_match 51 | slice = @target.byteslice(@offset, BLOCK_SIZE) 52 | return [0, 0] unless @index.has_key?(slice) 53 | 54 | m_offset = m_size = 0 55 | 56 | @index[slice].each do |pos| 57 | remaining = remaining_bytes(pos) 58 | break if remaining <= m_size 59 | 60 | s = match_from(pos, remaining) 61 | next if m_size >= s - pos 62 | 63 | m_offset = pos 64 | m_size = s - pos 65 | end 66 | 67 | [m_offset, m_size] 68 | end 69 | 70 | def remaining_bytes(pos) 71 | source_remaining = @source.bytesize - pos 72 | target_remaining = @target.bytesize - @offset 73 | 74 | [source_remaining, target_remaining, MAX_COPY_SIZE].min 75 | end 76 | 77 | def match_from(pos, remaining) 78 | s, t = pos, @offset 79 | 80 | while remaining > 0 and @source.getbyte(s) == @target.getbyte(t) 81 | s, t = s + 1, t + 1 82 | remaining -= 1 83 | end 84 | 85 | s 86 | end 87 | 88 | def expand_match(m_offset, m_size) 89 | while m_offset > 0 and @source.getbyte(m_offset - 1) == @insert.last 90 | break if m_size == MAX_COPY_SIZE 91 | 92 | @offset -= 1 93 | m_offset -= 1 94 | m_size += 1 95 | 96 | @insert.pop 97 | end 98 | 99 | @offset += m_size 100 | [m_offset, m_size] 101 | end 102 | 103 | def push_insert 104 | @insert.push(@target.getbyte(@offset)) 105 | @offset += 1 106 | flush_insert(MAX_INSERT_SIZE) 107 | end 108 | 109 | def flush_insert(size = nil) 110 | return if size and @insert.size < size 111 | return if @insert.empty? 112 | 113 | @ops.push(Delta::Insert.new(@insert.pack("C*"))) 114 | @insert = [] 115 | end 116 | 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/workspace.rb: -------------------------------------------------------------------------------- 1 | require "fileutils" 2 | 3 | class Workspace 4 | MissingFile = Class.new(StandardError) 5 | NoPermission = Class.new(StandardError) 6 | 7 | IGNORE = [".", "..", ".git"] 8 | 9 | def initialize(pathname) 10 | @pathname = pathname 11 | end 12 | 13 | def list_files(path = @pathname) 14 | relative = path.relative_path_from(@pathname) 15 | 16 | if File.directory?(path) 17 | filenames = Dir.entries(path) - IGNORE 18 | filenames.flat_map { |name| list_files(path.join(name)) } 19 | elsif File.exist?(path) 20 | [relative] 21 | else 22 | raise MissingFile, "pathspec '#{ relative }' did not match any files" 23 | end 24 | end 25 | 26 | def list_dir(dirname) 27 | path = @pathname.join(dirname || "") 28 | entries = Dir.entries(path) - IGNORE 29 | stats = {} 30 | 31 | entries.each do |name| 32 | relative = path.join(name).relative_path_from(@pathname) 33 | stats[relative.to_s] = File.stat(path.join(name)) 34 | end 35 | 36 | stats 37 | end 38 | 39 | def read_file(path) 40 | File.read(@pathname.join(path)) 41 | rescue Errno::EACCES 42 | raise NoPermission, "open('#{ path }'): Permission denied" 43 | end 44 | 45 | def stat_file(path) 46 | File.stat(@pathname.join(path)) 47 | rescue Errno::ENOENT, Errno::ENOTDIR 48 | nil 49 | rescue Errno::EACCES 50 | raise NoPermission, "stat('#{ path }'): Permission denied" 51 | end 52 | 53 | def write_file(path, data, mode = nil, mkdir = false) 54 | full_path = @pathname.join(path) 55 | FileUtils.mkdir_p(full_path.dirname) if mkdir 56 | 57 | flags = File::WRONLY | File::CREAT | File::TRUNC 58 | File.open(full_path, flags) { |f| f.write(data) } 59 | 60 | File.chmod(mode, full_path) if mode 61 | end 62 | 63 | def remove(path) 64 | FileUtils.rm_rf(@pathname.join(path)) 65 | path.dirname.ascend { |dirname| remove_directory(dirname) } 66 | rescue Errno::ENOENT 67 | end 68 | 69 | def apply_migration(migration) 70 | apply_change_list(migration, :delete) 71 | migration.rmdirs.sort.reverse_each { |dir| remove_directory(dir) } 72 | 73 | migration.mkdirs.sort.each { |dir| make_directory(dir) } 74 | apply_change_list(migration, :update) 75 | apply_change_list(migration, :create) 76 | end 77 | 78 | private 79 | 80 | def remove_directory(dirname) 81 | Dir.rmdir(@pathname.join(dirname)) 82 | rescue Errno::ENOENT, Errno::ENOTDIR, Errno::ENOTEMPTY 83 | end 84 | 85 | def make_directory(dirname) 86 | path = @pathname.join(dirname) 87 | stat = stat_file(dirname) 88 | 89 | File.unlink(path) if stat&.file? 90 | Dir.mkdir(path) unless stat&.directory? 91 | end 92 | 93 | def apply_change_list(migration, action) 94 | migration.changes[action].each do |filename, entry| 95 | path = @pathname.join(filename) 96 | 97 | FileUtils.rm_rf(path) 98 | next if action == :delete 99 | 100 | flags = File::WRONLY | File::CREAT | File::EXCL 101 | data = migration.blob_data(entry.oid) 102 | 103 | File.open(path, flags) { |file| file.write(data) } 104 | File.chmod(entry.mode, path) 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/repository/sequencer.rb: -------------------------------------------------------------------------------- 1 | require "fileutils" 2 | 3 | require_relative "../config" 4 | require_relative "../lockfile" 5 | 6 | class Repository 7 | class Sequencer 8 | 9 | UNSAFE_MESSAGE = "You seem to have moved HEAD. Not rewinding, check your HEAD!" 10 | 11 | def initialize(repository) 12 | @repo = repository 13 | @pathname = repository.git_path.join("sequencer") 14 | @abort_path = @pathname.join("abort-safety") 15 | @head_path = @pathname.join("head") 16 | @todo_path = @pathname.join("todo") 17 | @config = Config.new(@pathname.join("opts")) 18 | @todo_file = nil 19 | @commands = [] 20 | end 21 | 22 | def start(options) 23 | Dir.mkdir(@pathname) 24 | 25 | head_oid = @repo.refs.read_head 26 | write_file(@head_path, head_oid) 27 | write_file(@abort_path, head_oid) 28 | 29 | @config.open_for_update 30 | options.each { |key, value| @config.set(["options", key], value) } 31 | @config.save 32 | 33 | open_todo_file 34 | end 35 | 36 | def get_option(name) 37 | @config.open 38 | @config.get(["options", name]) 39 | end 40 | 41 | def pick(commit) 42 | @commands.push([:pick, commit]) 43 | end 44 | 45 | def revert(commit) 46 | @commands.push([:revert, commit]) 47 | end 48 | 49 | def next_command 50 | @commands.first 51 | end 52 | 53 | def drop_command 54 | @commands.shift 55 | write_file(@abort_path, @repo.refs.read_head) 56 | end 57 | 58 | def load 59 | open_todo_file 60 | return unless File.file?(@todo_path) 61 | 62 | @commands = File.read(@todo_path).lines.map do |line| 63 | action, oid, _ = /^(\S+) (\S+) (.*)$/.match(line).captures 64 | 65 | oids = @repo.database.prefix_match(oid) 66 | commit = @repo.database.load(oids.first) 67 | [action.to_sym, commit] 68 | end 69 | end 70 | 71 | def dump 72 | return unless @todo_file 73 | 74 | @commands.each do |action, commit| 75 | short = @repo.database.short_oid(commit.oid) 76 | @todo_file.write("#{ action } #{ short } #{ commit.title_line }") 77 | end 78 | 79 | @todo_file.commit 80 | end 81 | 82 | def abort 83 | head_oid = File.read(@head_path).strip 84 | expected = File.read(@abort_path).strip 85 | actual = @repo.refs.read_head 86 | 87 | quit 88 | 89 | raise UNSAFE_MESSAGE unless actual == expected 90 | 91 | @repo.hard_reset(head_oid) 92 | orig_head = @repo.refs.update_head(head_oid) 93 | @repo.refs.update_ref(Refs::ORIG_HEAD, orig_head) 94 | end 95 | 96 | def quit 97 | FileUtils.rm_rf(@pathname) 98 | end 99 | 100 | private 101 | 102 | def write_file(path, content) 103 | lockfile = Lockfile.new(path) 104 | lockfile.hold_for_update 105 | lockfile.write(content) 106 | lockfile.write("\n") 107 | lockfile.commit 108 | end 109 | 110 | def open_todo_file 111 | return unless File.directory?(@pathname) 112 | 113 | @todo_file = Lockfile.new(@todo_path) 114 | @todo_file.hold_for_update 115 | end 116 | 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/command/checkout.rb: -------------------------------------------------------------------------------- 1 | require_relative "./base" 2 | require_relative "../revision" 3 | 4 | module Command 5 | class Checkout < Base 6 | 7 | DETACHED_HEAD_MESSAGE = <<~MSG 8 | You are in 'detached HEAD' state. You can look around, make experimental 9 | changes and commit them, and you can discard any commits you make in this 10 | state without impacting any branches by performing another checkout. 11 | 12 | If you want to create a new branch to retain commits you create, you may 13 | do so (now or later) by using the branch command. Example: 14 | 15 | jit branch 16 | MSG 17 | 18 | def run 19 | @target = @args[0] 20 | 21 | @current_ref = repo.refs.current_ref 22 | @current_oid = @current_ref.read_oid 23 | 24 | revision = Revision.new(repo, @target) 25 | @target_oid = revision.resolve(Revision::COMMIT) 26 | 27 | repo.index.load_for_update 28 | 29 | tree_diff = repo.database.tree_diff(@current_oid, @target_oid) 30 | migration = repo.migration(tree_diff) 31 | migration.apply_changes 32 | 33 | repo.index.write_updates 34 | repo.refs.set_head(@target, @target_oid) 35 | @new_ref = repo.refs.current_ref 36 | 37 | print_previous_head 38 | print_detachment_notice 39 | print_new_head 40 | 41 | exit 0 42 | 43 | rescue Repository::Migration::Conflict 44 | handle_migration_conflict(migration) 45 | 46 | rescue Revision::InvalidObject => error 47 | handle_invalid_object(revision, error) 48 | end 49 | 50 | private 51 | 52 | def handle_migration_conflict(migration) 53 | repo.index.release_lock 54 | 55 | migration.errors.each do |message| 56 | @stderr.puts "error: #{ message }" 57 | end 58 | @stderr.puts "Aborting" 59 | exit 1 60 | end 61 | 62 | def handle_invalid_object(revision, error) 63 | revision.errors.each do |err| 64 | @stderr.puts "error: #{ err.message }" 65 | err.hint.each { |line| @stderr.puts "hint: #{ line }" } 66 | end 67 | @stderr.puts "error: #{ error.message }" 68 | exit 1 69 | end 70 | 71 | def print_previous_head 72 | if @current_ref.head? and @current_oid != @target_oid 73 | print_head_position("Previous HEAD position was", @current_oid) 74 | end 75 | end 76 | 77 | def print_detachment_notice 78 | return unless @new_ref.head? and not @current_ref.head? 79 | 80 | @stderr.puts "Note: checking out '#{ @target }'." 81 | @stderr.puts "" 82 | @stderr.puts DETACHED_HEAD_MESSAGE 83 | @stderr.puts "" 84 | end 85 | 86 | def print_new_head 87 | if @new_ref.head? 88 | print_head_position("HEAD is now at", @target_oid) 89 | elsif @new_ref == @current_ref 90 | @stderr.puts "Already on '#{ @target }'" 91 | else 92 | @stderr.puts "Switched to branch '#{ @target }'" 93 | end 94 | end 95 | 96 | def print_head_position(message, oid) 97 | commit = repo.database.load(oid) 98 | short = repo.database.short_oid(commit.oid) 99 | 100 | @stderr.puts "#{ message } #{ short } #{ commit.title_line }" 101 | end 102 | 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/command/diff.rb: -------------------------------------------------------------------------------- 1 | require_relative "./base" 2 | require_relative "./shared/print_diff" 3 | 4 | module Command 5 | class Diff < Base 6 | 7 | include PrintDiff 8 | 9 | def define_options 10 | @options[:patch] = true 11 | define_print_diff_options 12 | 13 | @parser.on "--cached", "--staged" do 14 | @options[:cached] = true 15 | end 16 | 17 | @parser.on("-1", "--base") { @options[:stage] = 1 } 18 | @parser.on("-2", "--ours") { @options[:stage] = 2 } 19 | @parser.on("-3", "--theirs") { @options[:stage] = 3 } 20 | end 21 | 22 | def run 23 | repo.index.load 24 | @status = repo.status 25 | 26 | setup_pager 27 | 28 | if @options[:cached] 29 | diff_head_index 30 | elsif @args.size == 2 31 | diff_commits 32 | else 33 | diff_index_workspace 34 | end 35 | 36 | exit 0 37 | end 38 | 39 | private 40 | 41 | def diff_commits 42 | return unless @options[:patch] 43 | 44 | a, b = @args.map { |rev| Revision.new(repo, rev).resolve } 45 | print_commit_diff(a, b) 46 | end 47 | 48 | def diff_head_index 49 | return unless @options[:patch] 50 | 51 | @status.index_changes.each do |path, state| 52 | case state 53 | when :added then print_diff(from_nothing(path), from_index(path)) 54 | when :modified then print_diff(from_head(path), from_index(path)) 55 | when :deleted then print_diff(from_head(path), from_nothing(path)) 56 | end 57 | end 58 | end 59 | 60 | def diff_index_workspace 61 | return unless @options[:patch] 62 | 63 | paths = @status.conflicts.keys + @status.workspace_changes.keys 64 | 65 | paths.sort.each do |path| 66 | if @status.conflicts.has_key?(path) 67 | print_conflict_diff(path) 68 | else 69 | print_workspace_diff(path) 70 | end 71 | end 72 | end 73 | 74 | def print_conflict_diff(path) 75 | targets = (0..3).map { |stage| from_index(path, stage) } 76 | left, right = targets[2], targets[3] 77 | 78 | if @options[:stage] 79 | puts "* Unmerged path #{ path }" 80 | print_diff(targets[@options[:stage]], from_file(path)) 81 | elsif left and right 82 | print_combined_diff([left, right], from_file(path)) 83 | else 84 | puts "* Unmerged path #{ path }" 85 | end 86 | end 87 | 88 | def print_workspace_diff(path) 89 | case @status.workspace_changes[path] 90 | when :modified then print_diff(from_index(path), from_file(path)) 91 | when :deleted then print_diff(from_index(path), from_nothing(path)) 92 | end 93 | end 94 | 95 | def from_head(path) 96 | entry = @status.head_tree.fetch(path) 97 | from_entry(path, entry) 98 | end 99 | 100 | def from_index(path, stage = 0) 101 | entry = repo.index.entry_for_path(path, stage) 102 | entry ? from_entry(path, entry) : nil 103 | end 104 | 105 | def from_file(path) 106 | blob = Database::Blob.new(repo.workspace.read_file(path)) 107 | oid = repo.database.hash_object(blob) 108 | mode = Index::Entry.mode_for_stat(@status.stats[path]) 109 | 110 | Target.new(path, oid, mode.to_s(8), blob.data) 111 | end 112 | 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/command/rm.rb: -------------------------------------------------------------------------------- 1 | require "pathname" 2 | 3 | require_relative "./base" 4 | require_relative "../repository/inspector" 5 | 6 | module Command 7 | class Rm < Base 8 | 9 | BOTH_CHANGED = "staged content different from both the file and the HEAD" 10 | INDEX_CHANGED = "changes staged in the index" 11 | WORKSPACE_CHANGED = "local modifications" 12 | 13 | def define_options 14 | @parser.on("--cached") { @options[:cached] = true } 15 | @parser.on("-f", "--force") { @options[:force] = true } 16 | @parser.on("-r") { @options[:recursive] = true } 17 | end 18 | 19 | def run 20 | repo.index.load_for_update 21 | 22 | @head_oid = repo.refs.read_head 23 | @inspector = Repository::Inspector.new(repo) 24 | @uncommitted = [] 25 | @unstaged = [] 26 | @both_changed = [] 27 | 28 | @args = @args.flat_map { |path| expand_path(path) } 29 | .map { |path| Pathname.new(path) } 30 | 31 | @args.each { |path| plan_removal(path) } 32 | exit_on_errors 33 | 34 | @args.each { |path| remove_file(path) } 35 | repo.index.write_updates 36 | 37 | exit 0 38 | 39 | rescue => error 40 | repo.index.release_lock 41 | @stderr.puts "fatal: #{ error.message }" 42 | exit 128 43 | end 44 | 45 | private 46 | 47 | def expand_path(path) 48 | if repo.index.tracked_directory?(path) 49 | return repo.index.child_paths(path) if @options[:recursive] 50 | raise "not removing '#{ path }' recursively without -r" 51 | end 52 | 53 | return [path] if repo.index.tracked_file?(path) 54 | raise "pathspec '#{ path }' did not match any files" 55 | end 56 | 57 | def plan_removal(path) 58 | return if @options[:force] 59 | 60 | stat = repo.workspace.stat_file(path) 61 | raise "jit rm: '#{ path }': Operation not permitted" if stat&.directory? 62 | 63 | item = repo.database.load_tree_entry(@head_oid, path) 64 | entry = repo.index.entry_for_path(path) 65 | 66 | staged_change = @inspector.compare_tree_to_index(item, entry) 67 | unstaged_change = @inspector.compare_index_to_workspace(entry, stat) if stat 68 | 69 | if staged_change and unstaged_change 70 | @both_changed.push(path) 71 | elsif staged_change 72 | @uncommitted.push(path) unless @options[:cached] 73 | elsif unstaged_change 74 | @unstaged.push(path) unless @options[:cached] 75 | end 76 | end 77 | 78 | def remove_file(path) 79 | repo.index.remove(path) 80 | repo.workspace.remove(path) unless @options[:cached] 81 | puts "rm '#{ path }'" 82 | end 83 | 84 | def exit_on_errors 85 | return if [@both_changed, @uncommitted, @unstaged].all?(&:empty?) 86 | 87 | print_errors(@both_changed, BOTH_CHANGED) 88 | print_errors(@uncommitted, INDEX_CHANGED) 89 | print_errors(@unstaged, WORKSPACE_CHANGED) 90 | 91 | repo.index.release_lock 92 | exit 1 93 | end 94 | 95 | def print_errors(paths, message) 96 | return if paths.empty? 97 | 98 | files_have = (paths.size == 1) ? "file has" : "files have" 99 | 100 | @stderr.puts "error: the following #{ files_have } #{ message }:" 101 | paths.each { |path| @stderr.puts " #{ path }" } 102 | end 103 | 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/command/fetch.rb: -------------------------------------------------------------------------------- 1 | require "set" 2 | 3 | require_relative "./base" 4 | require_relative "./shared/fast_forward" 5 | require_relative "./shared/receive_objects" 6 | require_relative "./shared/remote_client" 7 | require_relative "../remotes" 8 | require_relative "../rev_list" 9 | 10 | module Command 11 | class Fetch < Base 12 | 13 | include FastForward 14 | include ReceiveObjects 15 | include RemoteClient 16 | 17 | CAPABILITIES = ["ofs-delta"] 18 | UPLOAD_PACK = "git-upload-pack" 19 | 20 | def define_options 21 | @parser.on("-f", "--force") { @options[:force] = true } 22 | 23 | @parser.on "--upload-pack=" do |uploader| 24 | @options[:uploader] = uploader 25 | end 26 | end 27 | 28 | def run 29 | configure 30 | start_agent("fetch", @uploader, @fetch_url, CAPABILITIES) 31 | 32 | recv_references 33 | send_want_list 34 | send_have_list 35 | recv_objects 36 | update_remote_refs 37 | 38 | exit (@errors.empty? ? 0 : 1) 39 | end 40 | 41 | private 42 | 43 | def configure 44 | current_branch = repo.refs.current_ref.short_name 45 | branch_remote = repo.config.get(["branch", current_branch, "remote"]) 46 | 47 | name = @args.fetch(0, branch_remote || Remotes::DEFAULT_REMOTE) 48 | remote = repo.remotes.get(name) 49 | 50 | @fetch_url = remote&.fetch_url || @args[0] 51 | @uploader = @options[:uploader] || remote&.uploader || UPLOAD_PACK 52 | @fetch_specs = (@args.size > 1) ? @args.drop(1) : remote&.fetch_specs 53 | end 54 | 55 | def send_want_list 56 | @targets = Remotes::Refspec.expand(@fetch_specs, @remote_refs.keys) 57 | wanted = Set.new 58 | 59 | @local_refs = {} 60 | 61 | @targets.each do |target, (source, _)| 62 | local_oid = repo.refs.read_ref(target) 63 | remote_oid = @remote_refs[source] 64 | 65 | next if local_oid == remote_oid 66 | 67 | @local_refs[target] = local_oid 68 | wanted.add(remote_oid) 69 | end 70 | 71 | wanted.each { |oid| @conn.send_packet("want #{ oid }") } 72 | @conn.send_packet(nil) 73 | 74 | exit 0 if wanted.empty? 75 | end 76 | 77 | def send_have_list 78 | options = { :all => true, :missing => true } 79 | rev_list = ::RevList.new(repo, [], options) 80 | 81 | rev_list.each { |commit| @conn.send_packet("have #{ commit.oid }") } 82 | @conn.send_packet("done") 83 | 84 | @conn.recv_until(Pack::SIGNATURE) {} 85 | end 86 | 87 | def recv_objects 88 | unpack_limit = repo.config.get(["fetch", "unpackLimit"]) 89 | recv_packed_objects(unpack_limit, Pack::SIGNATURE) 90 | end 91 | 92 | def update_remote_refs 93 | @stderr.puts "From #{ @fetch_url }" 94 | 95 | @errors = {} 96 | @local_refs.each { |target, oid| attempt_ref_update(target, oid) } 97 | end 98 | 99 | def attempt_ref_update(target, old_oid) 100 | source, forced = @targets[target] 101 | 102 | new_oid = @remote_refs[source] 103 | ref_names = [source, target] 104 | ff_error = fast_forward_error(old_oid, new_oid) 105 | 106 | if @options[:force] or forced or ff_error == nil 107 | repo.refs.update_ref(target, new_oid) 108 | else 109 | error = @errors[target] = ff_error 110 | end 111 | 112 | report_ref_update(ref_names, error, old_oid, new_oid, ff_error == nil) 113 | end 114 | 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /test/database/tree_diff_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "fileutils" 3 | require "pathname" 4 | 5 | require "database" 6 | 7 | FakeEntry = Struct.new(:path, :oid, :mode) do 8 | def parent_directories 9 | Pathname.new(path).descend.to_a[0..-2] 10 | end 11 | 12 | def basename 13 | Pathname.new(path).basename 14 | end 15 | end 16 | 17 | describe Database::TreeDiff do 18 | before { FileUtils.mkdir_p(db_path) } 19 | after { FileUtils.rm_rf(db_path) } 20 | 21 | def db_path 22 | Pathname.new(File.expand_path("../../test-objects", __FILE__)) 23 | end 24 | 25 | def store_tree(contents) 26 | database = Database.new(db_path) 27 | 28 | entries = contents.map do |path, data| 29 | blob = Database::Blob.new(data) 30 | database.store(blob) 31 | 32 | FakeEntry.new(path, blob.oid, 0100644) 33 | end 34 | 35 | tree = Database::Tree.build(entries) 36 | tree.traverse { |t| database.store(t) } 37 | 38 | tree.oid 39 | end 40 | 41 | def tree_diff(a, b) 42 | Database.new(db_path).tree_diff(a, b) 43 | end 44 | 45 | it "reports a changed file" do 46 | tree_a = store_tree \ 47 | "alice.txt" => "alice", 48 | "bob.txt" => "bob" 49 | 50 | tree_b = store_tree \ 51 | "alice.txt" => "changed", 52 | "bob.txt" => "bob" 53 | 54 | assert_equal tree_diff(tree_a, tree_b), \ 55 | Pathname.new("alice.txt") => [ 56 | Database::Entry.new("ca56b59dbf8c0884b1b9ceb306873b24b73de969", 0100644), 57 | Database::Entry.new("21fb1eca31e64cd3914025058b21992ab76edcf9", 0100644) 58 | ] 59 | end 60 | 61 | it "reports an added file" do 62 | tree_a = store_tree \ 63 | "alice.txt" => "alice" 64 | 65 | tree_b = store_tree \ 66 | "alice.txt" => "alice", 67 | "bob.txt" => "bob" 68 | 69 | assert_equal tree_diff(tree_a, tree_b), \ 70 | Pathname.new("bob.txt") => [ 71 | nil, 72 | Database::Entry.new("2529de8969e5ee206e572ed72a0389c3115ad95c", 0100644) 73 | ] 74 | end 75 | 76 | it "reports a deleted file" do 77 | tree_a = store_tree \ 78 | "alice.txt" => "alice", 79 | "bob.txt" => "bob" 80 | 81 | tree_b = store_tree \ 82 | "alice.txt" => "alice" 83 | 84 | assert_equal tree_diff(tree_a, tree_b), \ 85 | Pathname.new("bob.txt") => [ 86 | Database::Entry.new("2529de8969e5ee206e572ed72a0389c3115ad95c", 0100644), 87 | nil 88 | ] 89 | end 90 | 91 | it "reports an added file inside a directory" do 92 | tree_a = store_tree \ 93 | "1.txt" => "1", 94 | "outer/2.txt" => "2" 95 | 96 | tree_b = store_tree \ 97 | "1.txt" => "1", 98 | "outer/2.txt" => "2", 99 | "outer/new/4.txt" => "4" 100 | 101 | assert_equal tree_diff(tree_a, tree_b), \ 102 | Pathname.new("outer/new/4.txt") => [ 103 | nil, 104 | Database::Entry.new("bf0d87ab1b2b0ec1a11a3973d2845b42413d9767", 0100644) 105 | ] 106 | end 107 | 108 | it "reports a deleted file inside a directory" do 109 | tree_a = store_tree \ 110 | "1.txt" => "1", 111 | "outer/2.txt" => "2", 112 | "outer/inner/3.txt" => "3" 113 | 114 | tree_b = store_tree \ 115 | "1.txt" => "1", 116 | "outer/2.txt" => "2" 117 | 118 | assert_equal tree_diff(tree_a, tree_b), \ 119 | Pathname.new("outer/inner/3.txt") => [ 120 | Database::Entry.new("e440e5c842586965a7fb77deda2eca68612b1f53", 0100644), 121 | nil 122 | ] 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /test/command_helper.rb: -------------------------------------------------------------------------------- 1 | require "fileutils" 2 | require "pathname" 3 | 4 | require "command" 5 | require "editor" 6 | require "repository" 7 | 8 | module CommandHelper 9 | def self.included(suite) 10 | return unless suite.respond_to?(:before) 11 | 12 | suite.before { jit_cmd "init", repo_path.to_s } 13 | suite.after { FileUtils.rm_rf(repo_path) } 14 | end 15 | 16 | def repo_path 17 | Pathname.new(File.expand_path("../test-repo", __FILE__)) 18 | end 19 | 20 | def repo 21 | @repository ||= Repository.new(repo_path.join(".git")) 22 | end 23 | 24 | def write_file(name, contents) 25 | path = repo_path.join(name) 26 | FileUtils.mkdir_p(path.dirname) 27 | 28 | flags = File::RDWR | File::CREAT | File::TRUNC 29 | File.open(path, flags) { |file| file.write(contents) } 30 | end 31 | 32 | def touch(name) 33 | FileUtils.touch(repo_path.join(name)) 34 | end 35 | 36 | def make_executable(name) 37 | File.chmod(0755, repo_path.join(name)) 38 | end 39 | 40 | def make_unreadable(name) 41 | File.chmod(0200, repo_path.join(name)) 42 | end 43 | 44 | def mkdir(name) 45 | FileUtils.mkdir_p(repo_path.join(name)) 46 | end 47 | 48 | def delete(name) 49 | FileUtils.rm_rf(repo_path.join(name)) 50 | end 51 | 52 | def set_env(key, value) 53 | @env ||= {} 54 | @env[key] = value 55 | end 56 | 57 | def jit_cmd(*argv) 58 | @env ||= {} 59 | @stdin = StringIO.new 60 | @stdout = StringIO.new 61 | @stderr = StringIO.new 62 | 63 | @cmd = Command.execute(repo_path.to_s, @env, argv, @stdin, @stdout, @stderr) 64 | end 65 | 66 | def commit(message, time = nil, author = true) 67 | if author 68 | set_env("GIT_AUTHOR_NAME", "A. U. Thor") 69 | set_env("GIT_AUTHOR_EMAIL", "author@example.com") 70 | end 71 | Time.stub(:now, time || Time.now) { jit_cmd "commit", "-m", message } 72 | end 73 | 74 | def assert_status(status) 75 | assert_equal(status, @cmd.status) 76 | end 77 | 78 | def read_status 79 | @cmd.status 80 | end 81 | 82 | def assert_stdout(message) 83 | assert_equal(message, read_stream(@stdout)) 84 | end 85 | 86 | def assert_stderr(message) 87 | assert_equal(message, read_stream(@stderr)) 88 | end 89 | 90 | def read_stream(stream) 91 | stream.rewind 92 | stream.read 93 | end 94 | 95 | def read_stderr 96 | read_stream(@stderr) 97 | end 98 | 99 | def resolve_revision(expression) 100 | Revision.new(repo, expression).resolve 101 | end 102 | 103 | def load_commit(expression) 104 | repo.database.load(resolve_revision(expression)) 105 | end 106 | 107 | def assert_index(contents) 108 | files = {} 109 | repo.index.load 110 | 111 | repo.index.each_entry do |entry| 112 | files[entry.path] = repo.database.load(entry.oid).data 113 | end 114 | 115 | assert_equal(contents, files) 116 | end 117 | 118 | def assert_workspace(contents, repo = self.repo) 119 | files = {} 120 | 121 | repo.workspace.list_files.sort.each do |pathname| 122 | files[pathname.to_s] = repo.workspace.read_file(pathname) 123 | end 124 | 125 | assert_equal(contents, files) 126 | end 127 | 128 | def assert_noent(filename) 129 | refute File.exist?(repo_path.join(filename)) 130 | end 131 | 132 | def assert_executable(filename) 133 | assert File.executable?(repo_path.join(filename)) 134 | end 135 | 136 | class FakeEditor 137 | Editor.instance_methods(false).each { |m| define_method(m) { |*| } } 138 | end 139 | 140 | def stub_editor(message) 141 | Editor.stub(:edit, message, FakeEditor.new) { yield } 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /test/pack/xdelta_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | 3 | require "pack" 4 | require "pack/delta" 5 | require "pack/xdelta" 6 | 7 | # 0 16 32 48 8 | # +----------------+----------------+----------------+ 9 | # |the quick brown |fox jumps over t|he slow lazy dog| 10 | # +----------------+----------------+----------------+ 11 | 12 | describe Pack::XDelta do 13 | def assert_delta(source, target, expected) 14 | delta = Pack::XDelta.create_index(source) 15 | actual = delta.compress(target) 16 | 17 | assert_equal(expected, actual) 18 | end 19 | 20 | it "compresses a string" do 21 | source = "the quick brown fox jumps over the slow lazy dog" 22 | target = "a swift auburn fox jumps over three dormant hounds" 23 | 24 | assert_delta source, target, [ 25 | Pack::Delta::Insert.new("a swift aubur"), 26 | Pack::Delta::Copy.new(14, 19), 27 | Pack::Delta::Insert.new("ree dormant hounds") 28 | ] 29 | end 30 | 31 | it "compresses an incomplete block" do 32 | source = "the quick brown fox jumps over the slow lazy dog" 33 | target = "he quick brown fox jumps over trees" 34 | 35 | assert_delta source, target, [ 36 | Pack::Delta::Copy.new(1, 31), 37 | Pack::Delta::Insert.new("rees") 38 | ] 39 | end 40 | 41 | it "compresses as source start" do 42 | source = "the quick brown fox jumps over the slow lazy dog" 43 | target = "the quick brown " 44 | 45 | assert_delta source, target, [ 46 | Pack::Delta::Copy.new(0, 16) 47 | ] 48 | end 49 | 50 | it "compresses at source start with right expansion" do 51 | source = "the quick brown fox jumps over the slow lazy dog" 52 | target = "the quick brown fox hops" 53 | 54 | assert_delta source, target, [ 55 | Pack::Delta::Copy.new(0, 20), 56 | Pack::Delta::Insert.new("hops") 57 | ] 58 | end 59 | 60 | it "compresses at source start with left offset" do 61 | source = "the quick brown fox jumps over the slow lazy dog" 62 | target = "behold the quick brown foal" 63 | 64 | assert_delta source, target, [ 65 | Pack::Delta::Insert.new("behold "), 66 | Pack::Delta::Copy.new(0, 18), 67 | Pack::Delta::Insert.new("al") 68 | ] 69 | end 70 | 71 | it "compresses at source end" do 72 | source = "the quick brown fox jumps over the slow lazy dog" 73 | target = "he slow lazy dog" 74 | 75 | assert_delta source, target, [ 76 | Pack::Delta::Copy.new(32, 16) 77 | ] 78 | end 79 | 80 | it "compresses at source end with left expansion" do 81 | source = "the quick brown fox jumps over the slow lazy dog" 82 | target = "under the slow lazy dog" 83 | 84 | assert_delta source, target, [ 85 | Pack::Delta::Insert.new("und"), 86 | Pack::Delta::Copy.new(28, 20) 87 | ] 88 | end 89 | 90 | it "compresses at source end with right offset" do 91 | source = "the quick brown fox jumps over the slow lazy dog" 92 | target = "under the slow lazy dog's legs" 93 | 94 | assert_delta source, target, [ 95 | Pack::Delta::Insert.new("und"), 96 | Pack::Delta::Copy.new(28, 20), 97 | Pack::Delta::Insert.new("'s legs") 98 | ] 99 | end 100 | 101 | it "compresses unindexed bytes" do 102 | source = "the quick brown fox" 103 | target = "see the quick brown fox" 104 | 105 | assert_delta source, target, [ 106 | Pack::Delta::Insert.new("see "), 107 | Pack::Delta::Copy.new(0, 19) 108 | ] 109 | end 110 | 111 | it "does not compress unindexed bytes" do 112 | source = "the quick brown fox" 113 | target = "a quick brown fox" 114 | 115 | assert_delta source, target, [ 116 | Pack::Delta::Insert.new("a quick brown fox") 117 | ] 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/command/config.rb: -------------------------------------------------------------------------------- 1 | require_relative "./base" 2 | 3 | module Command 4 | class Config < Base 5 | 6 | def define_options 7 | @parser.on("--local") { @options[:file] = :local } 8 | @parser.on("--global") { @options[:file] = :global } 9 | @parser.on("--system") { @options[:file] = :system } 10 | 11 | @parser.on "-f ", "--file=" do |file| 12 | @options[:file] = file 13 | end 14 | 15 | @parser.on("--add ") { |name| @options[:add] = name } 16 | @parser.on("--replace-all ") { |name| @options[:replace] = name } 17 | @parser.on("--get-all ") { |name| @options[:get_all] = name } 18 | @parser.on("--unset ") { |name| @options[:unset] = name } 19 | @parser.on("--unset-all ") { |name| @options[:unset_all] = name } 20 | 21 | @parser.on "--remove-section " do |name| 22 | @options[:remove_section] = name 23 | end 24 | end 25 | 26 | def run 27 | add_variable if @options[:add] 28 | replace_variable if @options[:replace] 29 | get_all_values if @options[:get_all] 30 | unset_single if @options[:unset] 31 | unset_all if @options[:unset_all] 32 | remove_section if @options[:remove_section] 33 | 34 | key, value = parse_key(@args[0]), @args[1] 35 | 36 | if value 37 | edit_config { |config| config.set(key, value) } 38 | else 39 | read_config { |config| [*config.get(key)] } 40 | end 41 | 42 | rescue ::Config::ParseError => error 43 | @stderr.puts "error: #{ error.message }" 44 | exit 3 45 | end 46 | 47 | private 48 | 49 | def add_variable 50 | key = parse_key(@options[:add]) 51 | edit_config { |config| config.add(key, @args[0]) } 52 | end 53 | 54 | def replace_variable 55 | key = parse_key(@options[:replace]) 56 | edit_config { |config| config.replace_all(key, @args[0]) } 57 | end 58 | 59 | def unset_single 60 | key = parse_key(@options[:unset]) 61 | edit_config { |config| config.unset(key) } 62 | end 63 | 64 | def unset_all 65 | key = parse_key(@options[:unset_all]) 66 | edit_config { |config| config.unset_all(key) } 67 | end 68 | 69 | def remove_section 70 | key = @options[:remove_section].split(".", 2) 71 | edit_config { |config| config.remove_section(key) } 72 | end 73 | 74 | def get_all_values 75 | key = parse_key(@options[:get_all]) 76 | read_config { |config| config.get_all(key) } 77 | end 78 | 79 | def read_config 80 | config = repo.config 81 | config = config.file(@options[:file]) if @options[:file] 82 | 83 | config.open 84 | values = yield config 85 | 86 | exit 1 if values.empty? 87 | 88 | values.each { |value| puts value } 89 | exit 0 90 | end 91 | 92 | def edit_config 93 | config = repo.config.file(@options.fetch(:file, :local)) 94 | config.open_for_update 95 | yield config 96 | config.save 97 | 98 | exit 0 99 | 100 | rescue ::Config::Conflict => error 101 | @stderr.puts "error: #{ error.message }" 102 | exit 5 103 | end 104 | 105 | def parse_key(name) 106 | section, *subsection, var = name.split(".") 107 | 108 | unless var 109 | @stderr.puts "error: key does not contain a section: #{ name }" 110 | exit 2 111 | end 112 | 113 | unless ::Config.valid_key?([section, var]) 114 | @stderr.puts "error: invalid key: #{ name }" 115 | exit 1 116 | end 117 | 118 | if subsection.empty? 119 | [section, var] 120 | else 121 | [section, subsection.join("."), var] 122 | end 123 | end 124 | 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/merge/diff3.rb: -------------------------------------------------------------------------------- 1 | require_relative "../diff" 2 | 3 | module Merge 4 | class Diff3 5 | 6 | Clean = Struct.new(:lines) do 7 | def to_s(*) 8 | lines.join("") 9 | end 10 | end 11 | 12 | Conflict = Struct.new(:o_lines, :a_lines, :b_lines) do 13 | def to_s(a_name = nil, b_name = nil) 14 | text = "" 15 | separator(text, "<", a_name) 16 | a_lines.each { |line| text.concat(line) } 17 | separator(text, "=") 18 | b_lines.each { |line| text.concat(line) } 19 | separator(text, ">", b_name) 20 | text 21 | end 22 | 23 | def separator(text, char, name = nil) 24 | text.concat(char * 7) 25 | text.concat(" #{ name }") if name 26 | text.concat("\n") 27 | end 28 | end 29 | 30 | Result = Struct.new(:chunks) do 31 | def clean? 32 | chunks.none? { |chunk| chunk.is_a?(Conflict) } 33 | end 34 | 35 | def to_s(a_name = nil, b_name = nil) 36 | chunks.map { |chunk| chunk.to_s(a_name, b_name) }.join("") 37 | end 38 | end 39 | 40 | def self.merge(o, a, b) 41 | o = o.lines if o.is_a?(String) 42 | a = a.lines if a.is_a?(String) 43 | b = b.lines if b.is_a?(String) 44 | 45 | Diff3.new(o, a, b).merge 46 | end 47 | 48 | def initialize(o, a, b) 49 | @o, @a, @b = o, a, b 50 | end 51 | 52 | def merge 53 | setup 54 | generate_chunks 55 | Result.new(@chunks) 56 | end 57 | 58 | def setup 59 | @chunks = [] 60 | @line_o = @line_a = @line_b = 0 61 | 62 | @match_a = match_set(@a) 63 | @match_b = match_set(@b) 64 | end 65 | 66 | def match_set(file) 67 | matches = {} 68 | 69 | Diff.diff(@o, file).each do |edit| 70 | next unless edit.type == :eql 71 | matches[edit.a_line.number] = edit.b_line.number 72 | end 73 | 74 | matches 75 | end 76 | 77 | def generate_chunks 78 | loop do 79 | i = find_next_mismatch 80 | 81 | if i == 1 82 | o, a, b = find_next_match 83 | 84 | if a and b 85 | emit_chunk(o, a, b) 86 | else 87 | emit_final_chunk 88 | return 89 | end 90 | 91 | elsif i 92 | emit_chunk(@line_o + i, @line_a + i, @line_b + i) 93 | 94 | else 95 | emit_final_chunk 96 | return 97 | end 98 | end 99 | end 100 | 101 | def find_next_mismatch 102 | i = 1 103 | while in_bounds?(i) and 104 | match?(@match_a, @line_a, i) and 105 | match?(@match_b, @line_b, i) 106 | i += 1 107 | end 108 | in_bounds?(i) ? i : nil 109 | end 110 | 111 | def in_bounds?(i) 112 | @line_o + i <= @o.size or 113 | @line_a + i <= @a.size or 114 | @line_b + i <= @b.size 115 | end 116 | 117 | def match?(matches, offset, i) 118 | matches[@line_o + i] == offset + i 119 | end 120 | 121 | def find_next_match 122 | o = @line_o + 1 123 | until o > @o.size or (@match_a.has_key?(o) and @match_b.has_key?(o)) 124 | o += 1 125 | end 126 | [o, @match_a[o], @match_b[o]] 127 | end 128 | 129 | def emit_chunk(o, a, b) 130 | write_chunk( 131 | @o[@line_o ... o - 1], 132 | @a[@line_a ... a - 1], 133 | @b[@line_b ... b - 1]) 134 | 135 | @line_o, @line_a, @line_b = o - 1, a - 1, b - 1 136 | end 137 | 138 | def emit_final_chunk 139 | write_chunk( 140 | @o[@line_o .. -1], 141 | @a[@line_a .. -1], 142 | @b[@line_b .. -1]) 143 | end 144 | 145 | def write_chunk(o, a, b) 146 | if a == o or a == b 147 | @chunks.push(Clean.new(b)) 148 | elsif b == o 149 | @chunks.push(Clean.new(a)) 150 | else 151 | @chunks.push(Conflict.new(o, a, b)) 152 | end 153 | end 154 | 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /lib/command/shared/print_diff.rb: -------------------------------------------------------------------------------- 1 | require "pathname" 2 | require_relative "../../diff" 3 | 4 | module Command 5 | module PrintDiff 6 | 7 | DIFF_FORMATS = { 8 | :context => :normal, 9 | :meta => :bold, 10 | :frag => :cyan, 11 | :old => :red, 12 | :new => :green 13 | } 14 | 15 | NULL_OID = "0" * 40 16 | NULL_PATH = "/dev/null" 17 | 18 | Target = Struct.new(:path, :oid, :mode, :data) do 19 | def diff_path 20 | mode ? path : NULL_PATH 21 | end 22 | end 23 | 24 | def define_print_diff_options 25 | @parser.on("-p", "-u", "--patch") { @options[:patch] = true } 26 | @parser.on("-s", "--no-patch") { @options[:patch] = false } 27 | end 28 | 29 | private 30 | 31 | def from_entry(path, entry) 32 | return from_nothing(path) unless entry 33 | 34 | blob = repo.database.load(entry.oid) 35 | Target.new(path, entry.oid, entry.mode.to_s(8), blob.data) 36 | end 37 | 38 | def from_nothing(path) 39 | Target.new(path, NULL_OID, nil, "") 40 | end 41 | 42 | def diff_fmt(name, text) 43 | key = ["color", "diff", name] 44 | style = repo.config.get(key)&.split(/ +/) || DIFF_FORMATS.fetch(name) 45 | 46 | fmt(style, text) 47 | end 48 | 49 | def header(string) 50 | puts diff_fmt(:meta, string) 51 | end 52 | 53 | def short(oid) 54 | repo.database.short_oid(oid) 55 | end 56 | 57 | def print_commit_diff(a, b, differ = nil) 58 | differ ||= repo.database 59 | diff = differ.tree_diff(a, b) 60 | paths = diff.keys.sort_by(&:to_s) 61 | 62 | paths.each do |path| 63 | old_entry, new_entry = diff[path] 64 | print_diff(from_entry(path, old_entry), from_entry(path, new_entry)) 65 | end 66 | end 67 | 68 | def print_diff(a, b) 69 | return if a.oid == b.oid and a.mode == b.mode 70 | 71 | a.path = Pathname.new("a").join(a.path) 72 | b.path = Pathname.new("b").join(b.path) 73 | 74 | header("diff --git #{ a.path } #{ b.path }") 75 | print_diff_mode(a, b) 76 | print_diff_content(a, b) 77 | end 78 | 79 | def print_diff_mode(a, b) 80 | if a.mode == nil 81 | header("new file mode #{ b.mode }") 82 | elsif b.mode == nil 83 | header("deleted file mode #{ a.mode }") 84 | elsif a.mode != b.mode 85 | header("old mode #{ a.mode }") 86 | header("new mode #{ b.mode }") 87 | end 88 | end 89 | 90 | def print_diff_content(a, b) 91 | return if a.oid == b.oid 92 | 93 | oid_range = "index #{ short a.oid }..#{ short b.oid }" 94 | oid_range.concat(" #{ a.mode }") if a.mode == b.mode 95 | 96 | header(oid_range) 97 | header("--- #{ a.diff_path }") 98 | header("+++ #{ b.diff_path }") 99 | 100 | hunks = ::Diff.diff_hunks(a.data, b.data) 101 | hunks.each { |hunk| print_diff_hunk(hunk) } 102 | end 103 | 104 | def print_combined_diff(as, b) 105 | header("diff --cc #{ b.path }") 106 | 107 | a_oids = as.map { |a| short a.oid } 108 | oid_range = "index #{ a_oids.join(",") }..#{ short b.oid }" 109 | header(oid_range) 110 | 111 | unless as.all? { |a| a.mode == b.mode } 112 | header("mode #{ as.map(&:mode).join(",") }..#{ b.mode }") 113 | end 114 | 115 | header("--- a/#{ b.diff_path }") 116 | header("+++ b/#{ b.diff_path }") 117 | 118 | hunks = ::Diff.combined_hunks(as.map(&:data), b.data) 119 | hunks.each { |hunk| print_diff_hunk(hunk) } 120 | end 121 | 122 | def print_diff_hunk(hunk) 123 | puts diff_fmt(:frag, hunk.header) 124 | hunk.edits.each { |edit| print_diff_edit(edit) } 125 | end 126 | 127 | def print_diff_edit(edit) 128 | text = edit.to_s.rstrip 129 | 130 | case edit.type 131 | when :eql then puts diff_fmt(:context, text) 132 | when :ins then puts diff_fmt(:new, text) 133 | when :del then puts diff_fmt(:old, text) 134 | end 135 | end 136 | 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /test/command/config_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "command_helper" 3 | 4 | describe Command::Config do 5 | include CommandHelper 6 | 7 | it "returns 1 for unknown variables" do 8 | jit_cmd "config", "--local", "no.such" 9 | assert_status 1 10 | end 11 | 12 | it "returns 1 when the key is invalid" do 13 | jit_cmd "config", "--local", "0.0" 14 | assert_status 1 15 | assert_stderr "error: invalid key: 0.0\n" 16 | end 17 | 18 | it "returns 2 when no section is given" do 19 | jit_cmd "config", "--local", "no" 20 | assert_status 2 21 | assert_stderr "error: key does not contain a section: no\n" 22 | end 23 | 24 | it "returns the value of a set variable" do 25 | jit_cmd "config", "core.editor", "ed" 26 | 27 | jit_cmd "config", "--local", "Core.Editor" 28 | assert_status 0 29 | assert_stdout "ed\n" 30 | end 31 | 32 | it "returns the value of a set variable in a subsection" do 33 | jit_cmd "config", "remote.origin.url", "git@github.com:jcoglan.jit" 34 | 35 | jit_cmd "config", "--local", "Remote.origin.URL" 36 | assert_status 0 37 | assert_stdout "git@github.com:jcoglan.jit\n" 38 | end 39 | 40 | it "unsets a variable" do 41 | jit_cmd "config", "core.editor", "ed" 42 | jit_cmd "config", "--unset", "core.editor" 43 | 44 | jit_cmd "config", "--local", "Core.Editor" 45 | assert_status 1 46 | end 47 | 48 | describe "with multi-valued variables" do 49 | before do 50 | jit_cmd "config", "--add", "remote.origin.fetch", "master" 51 | jit_cmd "config", "--add", "remote.origin.fetch", "topic" 52 | end 53 | 54 | it "returns the last value" do 55 | jit_cmd "config", "remote.origin.fetch" 56 | assert_status 0 57 | assert_stdout "topic\n" 58 | end 59 | 60 | it "returns all the values" do 61 | jit_cmd "config", "--get-all", "remote.origin.fetch" 62 | assert_status 0 63 | 64 | assert_stdout <<~MSG 65 | master 66 | topic 67 | MSG 68 | end 69 | 70 | it "returns 5 on trying to set a variable" do 71 | jit_cmd "config", "remote.origin.fetch", "new-value" 72 | assert_status 5 73 | 74 | jit_cmd "config", "--get-all", "remote.origin.fetch" 75 | assert_status 0 76 | 77 | assert_stdout <<~MSG 78 | master 79 | topic 80 | MSG 81 | end 82 | 83 | it "replaces a variable" do 84 | jit_cmd "config", "--replace-all", "remote.origin.fetch", "new-value" 85 | 86 | jit_cmd "config", "--get-all", "remote.origin.fetch" 87 | assert_status 0 88 | assert_stdout "new-value\n" 89 | end 90 | 91 | it "returns 5 on trying to unset a variable" do 92 | jit_cmd "config", "--unset", "remote.origin.fetch" 93 | assert_status 5 94 | 95 | jit_cmd "config", "--get-all", "remote.origin.fetch" 96 | assert_status 0 97 | 98 | assert_stdout <<~MSG 99 | master 100 | topic 101 | MSG 102 | end 103 | 104 | it "unsets a variable" do 105 | jit_cmd "config", "--unset-all", "remote.origin.fetch" 106 | 107 | jit_cmd "config", "--get-all", "remote.origin.fetch" 108 | assert_status 1 109 | end 110 | end 111 | 112 | it "removes a section" do 113 | jit_cmd "config", "core.editor", "ed" 114 | jit_cmd "config", "remote.origin.url", "ssh://example.com/repo" 115 | jit_cmd "config", "--remove-section", "core" 116 | 117 | jit_cmd "config", "--local", "remote.origin.url" 118 | assert_status 0 119 | assert_stdout "ssh://example.com/repo\n" 120 | 121 | jit_cmd "config", "--local", "core.editor" 122 | assert_status 1 123 | end 124 | 125 | it "removes a subsection" do 126 | jit_cmd "config", "core.editor", "ed" 127 | jit_cmd "config", "remote.origin.url", "ssh://example.com/repo" 128 | jit_cmd "config", "--remove-section", "remote.origin" 129 | 130 | jit_cmd "config", "--local", "core.editor" 131 | assert_status 0 132 | assert_stdout "ed\n" 133 | 134 | jit_cmd "config", "--local", "remote.origin.url" 135 | assert_status 1 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /lib/revision.rb: -------------------------------------------------------------------------------- 1 | class Revision 2 | InvalidObject = Class.new(StandardError) 3 | HintedError = Struct.new(:message, :hint) 4 | 5 | Ref = Struct.new(:name) do 6 | def resolve(context) 7 | context.read_ref(name) 8 | end 9 | end 10 | 11 | Parent = Struct.new(:rev, :n) do 12 | def resolve(context) 13 | context.commit_parent(rev.resolve(context), n) 14 | end 15 | end 16 | 17 | Ancestor = Struct.new(:rev, :n) do 18 | def resolve(context) 19 | oid = rev.resolve(context) 20 | n.times { oid = context.commit_parent(oid) } 21 | oid 22 | end 23 | end 24 | 25 | Upstream = Struct.new(:rev) do 26 | def resolve(context) 27 | name = context.upstream(rev.name) 28 | context.read_ref(name) 29 | end 30 | end 31 | 32 | INVALID_NAME = / 33 | ^\. 34 | | \/\. 35 | | \.\. 36 | | ^\/ 37 | | \/$ 38 | | \.lock$ 39 | | @\{ 40 | | [\x00-\x20*:?\[\\^~\x7f] 41 | /x 42 | 43 | PARENT = /^(.+)\^(\d*)$/ 44 | ANCESTOR = /^(.+)~(\d+)$/ 45 | UPSTREAM = /^(.*)@\{u(pstream)?\}$/i 46 | 47 | HEAD = "HEAD" 48 | 49 | REF_ALIASES = { 50 | "@" => HEAD, 51 | "" => HEAD 52 | } 53 | 54 | COMMIT = "commit" 55 | 56 | def self.parse(revision) 57 | if match = PARENT.match(revision) 58 | rev = Revision.parse(match[1]) 59 | n = (match[2] == "") ? 1 : match[2].to_i 60 | rev ? Parent.new(rev, n) : nil 61 | elsif match = ANCESTOR.match(revision) 62 | rev = Revision.parse(match[1]) 63 | rev ? Ancestor.new(rev, match[2].to_i) : nil 64 | elsif match = UPSTREAM.match(revision) 65 | rev = Revision.parse(match[1]) 66 | rev ? Upstream.new(rev) : nil 67 | elsif Revision.valid_ref?(revision) 68 | name = REF_ALIASES[revision] || revision 69 | Ref.new(name) 70 | end 71 | end 72 | 73 | def self.valid_ref?(revision) 74 | INVALID_NAME =~ revision ? false : true 75 | end 76 | 77 | attr_reader :errors 78 | 79 | def initialize(repo, expression) 80 | @repo = repo 81 | @expr = expression 82 | @query = Revision.parse(@expr) 83 | @errors = [] 84 | end 85 | 86 | def resolve(type = nil) 87 | oid = @query&.resolve(self) 88 | oid = nil if type and not load_typed_object(oid, type) 89 | 90 | return oid if oid 91 | 92 | raise InvalidObject, "Not a valid object name: '#{ @expr }'." 93 | end 94 | 95 | def read_ref(name) 96 | oid = @repo.refs.read_ref(name) 97 | return oid if oid 98 | 99 | candidates = @repo.database.prefix_match(name) 100 | return candidates.first if candidates.size == 1 101 | 102 | if candidates.size > 1 103 | log_ambiguous_sha1(name, candidates) 104 | end 105 | 106 | nil 107 | end 108 | 109 | def commit_parent(oid, n = 1) 110 | return nil unless oid 111 | 112 | commit = load_typed_object(oid, COMMIT) 113 | return nil unless commit 114 | 115 | commit.parents[n - 1] 116 | end 117 | 118 | def upstream(branch) 119 | branch = @repo.refs.current_ref.short_name if branch == HEAD 120 | @repo.remotes.get_upstream(branch) 121 | end 122 | 123 | private 124 | 125 | def load_typed_object(oid, type) 126 | return nil unless oid 127 | 128 | object = @repo.database.load(oid) 129 | 130 | if object.type == type 131 | object 132 | else 133 | message = "object #{ oid } is a #{ object.type }, not a #{ type }" 134 | @errors.push(HintedError.new(message, [])) 135 | nil 136 | end 137 | end 138 | 139 | def log_ambiguous_sha1(name, candidates) 140 | objects = candidates.sort.map do |oid| 141 | object = @repo.database.load(oid) 142 | short = @repo.database.short_oid(object.oid) 143 | info = " #{ short } #{ object.type }" 144 | 145 | if object.type == COMMIT 146 | "#{ info } #{ object.author.short_date } - #{ object.title_line }" 147 | else 148 | info 149 | end 150 | end 151 | 152 | message = "short SHA1 #{ name } is ambiguous" 153 | hint = ["The candidates are:"] + objects 154 | @errors.push(HintedError.new(message, hint)) 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /lib/command/shared/sequencing.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../merge/resolve" 2 | require_relative "../../repository/sequencer" 3 | 4 | module Command 5 | module Sequencing 6 | 7 | CONFLICT_NOTES = <<~MSG 8 | after resolving the conflicts, mark the corrected paths 9 | with 'jit add ' or 'jit rm ' 10 | and commit the result with 'jit commit' 11 | MSG 12 | 13 | def define_options 14 | @options[:mode] = :run 15 | 16 | @parser.on("--continue") { @options[:mode] = :continue } 17 | @parser.on("--abort") { @options[:mode] = :abort } 18 | @parser.on("--quit" ) { @options[:mode] = :quit } 19 | 20 | @parser.on "-m ", "--mainline=", Integer do |parent| 21 | @options[:mainline] = parent 22 | end 23 | end 24 | 25 | def run 26 | case @options[:mode] 27 | when :continue then handle_continue 28 | when :abort then handle_abort 29 | when :quit then handle_quit 30 | end 31 | 32 | sequencer.start(@options) 33 | store_commit_sequence 34 | resume_sequencer 35 | end 36 | 37 | private 38 | 39 | def sequencer 40 | @sequencer ||= Repository::Sequencer.new(repo) 41 | end 42 | 43 | def resolve_merge(inputs) 44 | repo.index.load_for_update 45 | ::Merge::Resolve.new(repo, inputs).execute 46 | repo.index.write_updates 47 | end 48 | 49 | def fail_on_conflict(inputs, message) 50 | sequencer.dump 51 | pending_commit.start(inputs.right_oid, merge_type) 52 | 53 | edit_file(pending_commit.message_path) do |editor| 54 | editor.puts(message) 55 | editor.puts("") 56 | editor.note("Conflicts:") 57 | repo.index.conflict_paths.each { |name| editor.note("\t#{ name }") } 58 | editor.close 59 | end 60 | 61 | @stderr.puts "error: could not apply #{ inputs.right_name }" 62 | CONFLICT_NOTES.each_line { |line| @stderr.puts "hint: #{ line }" } 63 | exit 1 64 | end 65 | 66 | def finish_commit(commit) 67 | repo.database.store(commit) 68 | repo.refs.update_head(commit.oid) 69 | print_commit(commit) 70 | end 71 | 72 | def handle_continue 73 | repo.index.load 74 | 75 | case pending_commit.merge_type 76 | when :cherry_pick then write_cherry_pick_commit 77 | when :revert then write_revert_commit 78 | end 79 | 80 | sequencer.load 81 | sequencer.drop_command 82 | resume_sequencer 83 | 84 | rescue Repository::PendingCommit::Error => error 85 | @stderr.puts "fatal: #{ error.message }" 86 | exit 128 87 | end 88 | 89 | def resume_sequencer 90 | loop do 91 | action, commit = sequencer.next_command 92 | break unless commit 93 | 94 | case action 95 | when :pick then pick(commit) 96 | when :revert then revert(commit) 97 | end 98 | sequencer.drop_command 99 | end 100 | 101 | sequencer.quit 102 | exit 0 103 | end 104 | 105 | def select_parent(commit) 106 | mainline = sequencer.get_option("mainline") 107 | 108 | if commit.merge? 109 | return commit.parents[mainline - 1] if mainline 110 | 111 | @stderr.puts <<~ERROR 112 | error: commit #{ commit.oid } is a merge but no -m option was given 113 | ERROR 114 | exit 1 115 | else 116 | return commit.parent unless mainline 117 | 118 | @stderr.puts <<~ERROR 119 | error: mainline was specified but commit #{ commit.oid } is not a merge 120 | ERROR 121 | exit 1 122 | end 123 | end 124 | 125 | def handle_abort 126 | pending_commit.clear(merge_type) if pending_commit.in_progress? 127 | repo.index.load_for_update 128 | 129 | begin 130 | sequencer.abort 131 | rescue => error 132 | @stderr.puts "warning: #{ error.message }" 133 | end 134 | 135 | repo.index.write_updates 136 | exit 0 137 | end 138 | 139 | def handle_quit 140 | pending_commit.clear(merge_type) if pending_commit.in_progress? 141 | sequencer.quit 142 | exit 0 143 | end 144 | 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /lib/command/merge.rb: -------------------------------------------------------------------------------- 1 | require_relative "./base" 2 | require_relative "./shared/write_commit" 3 | require_relative "../merge/inputs" 4 | require_relative "../merge/resolve" 5 | require_relative "../revision" 6 | 7 | module Command 8 | class Merge < Base 9 | 10 | include WriteCommit 11 | 12 | COMMIT_NOTES = <<~MSG 13 | Please enter a commit message to explain why this merge is necessary, 14 | especially if it merges an updated upstream into a topic branch. 15 | 16 | Lines starting with '#' will be ignored, and an empty message aborts 17 | the commit. 18 | MSG 19 | 20 | def define_options 21 | define_write_commit_options 22 | 23 | @options[:mode] = :run 24 | 25 | @parser.on("--abort") { @options[:mode] = :abort } 26 | @parser.on("--continue") { @options[:mode] = :continue } 27 | end 28 | 29 | def run 30 | handle_abort if @options[:mode] == :abort 31 | handle_continue if @options[:mode] == :continue 32 | handle_in_progress_merge if pending_commit.in_progress? 33 | 34 | @inputs = ::Merge::Inputs.new(repo, Revision::HEAD, @args[0]) 35 | repo.refs.update_ref(Refs::ORIG_HEAD, @inputs.left_oid) 36 | 37 | handle_merged_ancestor if @inputs.already_merged? 38 | handle_fast_forward if @inputs.fast_forward? 39 | 40 | pending_commit.start(@inputs.right_oid) 41 | resolve_merge 42 | commit_merge 43 | 44 | exit 0 45 | end 46 | 47 | private 48 | 49 | def resolve_merge 50 | repo.index.load_for_update 51 | 52 | merge = ::Merge::Resolve.new(repo, @inputs) 53 | merge.on_progress { |info| puts info } 54 | merge.execute 55 | 56 | repo.index.write_updates 57 | fail_on_conflict if repo.index.conflict? 58 | end 59 | 60 | def fail_on_conflict 61 | edit_file(pending_commit.message_path) do |editor| 62 | editor.puts(read_message || default_commit_message) 63 | editor.puts("") 64 | editor.note("Conflicts:") 65 | repo.index.conflict_paths.each { |name| editor.note("\t#{ name }") } 66 | editor.close 67 | end 68 | 69 | puts "Automatic merge failed; fix conflicts and then commit the result." 70 | exit 1 71 | end 72 | 73 | def commit_merge 74 | parents = [@inputs.left_oid, @inputs.right_oid] 75 | message = compose_message 76 | 77 | write_commit(parents, message) 78 | 79 | pending_commit.clear 80 | end 81 | 82 | def compose_message 83 | edit_file(pending_commit.message_path) do |editor| 84 | editor.puts(read_message || default_commit_message) 85 | editor.puts("") 86 | editor.note(COMMIT_NOTES) 87 | 88 | editor.close unless @options[:edit] 89 | end 90 | end 91 | 92 | def default_commit_message 93 | "Merge commit '#{ @inputs.right_name }'" 94 | end 95 | 96 | def handle_merged_ancestor 97 | puts "Already up to date." 98 | exit 0 99 | end 100 | 101 | def handle_fast_forward 102 | a = repo.database.short_oid(@inputs.left_oid) 103 | b = repo.database.short_oid(@inputs.right_oid) 104 | 105 | puts "Updating #{ a }..#{ b }" 106 | puts "Fast-forward" 107 | 108 | repo.index.load_for_update 109 | 110 | tree_diff = repo.database.tree_diff(@inputs.left_oid, @inputs.right_oid) 111 | repo.migration(tree_diff).apply_changes 112 | 113 | repo.index.write_updates 114 | repo.refs.update_head(@inputs.right_oid) 115 | 116 | exit 0 117 | end 118 | 119 | def handle_abort 120 | repo.pending_commit.clear 121 | 122 | repo.index.load_for_update 123 | repo.hard_reset(repo.refs.read_head) 124 | repo.index.write_updates 125 | 126 | exit 0 127 | rescue Repository::PendingCommit::Error => error 128 | @stderr.puts "fatal: #{ error.message }" 129 | exit 128 130 | end 131 | 132 | def handle_continue 133 | repo.index.load 134 | resume_merge(:merge) 135 | rescue Repository::PendingCommit::Error => error 136 | @stderr.puts "fatal: #{ error.message }" 137 | exit 128 138 | end 139 | 140 | def handle_in_progress_merge 141 | message = "Merging is not possible because you have unmerged files" 142 | @stderr.puts "error: #{ message }." 143 | @stderr.puts CONFLICT_MESSAGE 144 | exit 128 145 | end 146 | 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /test/command/diff_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "command_helper" 3 | 4 | describe Command::Diff do 5 | include CommandHelper 6 | 7 | def assert_diff(output) 8 | jit_cmd "diff" 9 | assert_stdout(output) 10 | end 11 | 12 | def assert_diff_cached(output) 13 | jit_cmd "diff", "--cached" 14 | assert_stdout(output) 15 | end 16 | 17 | describe "with a file in the index" do 18 | before do 19 | write_file "file.txt", <<~FILE 20 | contents 21 | FILE 22 | jit_cmd "add", "." 23 | end 24 | 25 | it "diffs a file with modified contents" do 26 | write_file "file.txt", <<~FILE 27 | changed 28 | FILE 29 | 30 | assert_diff <<~DIFF 31 | diff --git a/file.txt b/file.txt 32 | index 12f00e9..5ea2ed4 100644 33 | --- a/file.txt 34 | +++ b/file.txt 35 | @@ -1,1 +1,1 @@ 36 | -contents 37 | +changed 38 | DIFF 39 | end 40 | 41 | it "diffs a file with changed mode" do 42 | make_executable "file.txt" 43 | 44 | assert_diff <<~DIFF 45 | diff --git a/file.txt b/file.txt 46 | old mode 100644 47 | new mode 100755 48 | DIFF 49 | end 50 | 51 | it "diffs a file with changed mode and contents" do 52 | make_executable "file.txt" 53 | 54 | write_file "file.txt", <<~FILE 55 | changed 56 | FILE 57 | 58 | assert_diff <<~DIFF 59 | diff --git a/file.txt b/file.txt 60 | old mode 100644 61 | new mode 100755 62 | index 12f00e9..5ea2ed4 63 | --- a/file.txt 64 | +++ b/file.txt 65 | @@ -1,1 +1,1 @@ 66 | -contents 67 | +changed 68 | DIFF 69 | end 70 | 71 | it "diffs a deleted file" do 72 | delete "file.txt" 73 | 74 | assert_diff <<~DIFF 75 | diff --git a/file.txt b/file.txt 76 | deleted file mode 100644 77 | index 12f00e9..0000000 78 | --- a/file.txt 79 | +++ /dev/null 80 | @@ -1,1 +0,0 @@ 81 | -contents 82 | DIFF 83 | end 84 | end 85 | 86 | describe "with a HEAD commit" do 87 | before do 88 | write_file "file.txt", <<~FILE 89 | contents 90 | FILE 91 | jit_cmd "add", "." 92 | commit "first commit" 93 | end 94 | 95 | it "diffs a file with modified contents" do 96 | write_file "file.txt", <<~FILE 97 | changed 98 | FILE 99 | jit_cmd "add", "." 100 | 101 | assert_diff_cached <<~DIFF 102 | diff --git a/file.txt b/file.txt 103 | index 12f00e9..5ea2ed4 100644 104 | --- a/file.txt 105 | +++ b/file.txt 106 | @@ -1,1 +1,1 @@ 107 | -contents 108 | +changed 109 | DIFF 110 | end 111 | 112 | it "diffs a file with changed mode" do 113 | make_executable "file.txt" 114 | jit_cmd "add", "." 115 | 116 | assert_diff_cached <<~DIFF 117 | diff --git a/file.txt b/file.txt 118 | old mode 100644 119 | new mode 100755 120 | DIFF 121 | end 122 | 123 | it "diffs a file with changed mode and contents" do 124 | make_executable "file.txt" 125 | 126 | write_file "file.txt", <<~FILE 127 | changed 128 | FILE 129 | jit_cmd "add", "." 130 | 131 | assert_diff_cached <<~DIFF 132 | diff --git a/file.txt b/file.txt 133 | old mode 100644 134 | new mode 100755 135 | index 12f00e9..5ea2ed4 136 | --- a/file.txt 137 | +++ b/file.txt 138 | @@ -1,1 +1,1 @@ 139 | -contents 140 | +changed 141 | DIFF 142 | end 143 | 144 | it "diffs a deleted file" do 145 | delete "file.txt" 146 | delete ".git/index" 147 | jit_cmd "add", "." 148 | 149 | assert_diff_cached <<~DIFF 150 | diff --git a/file.txt b/file.txt 151 | deleted file mode 100644 152 | index 12f00e9..0000000 153 | --- a/file.txt 154 | +++ /dev/null 155 | @@ -1,1 +0,0 @@ 156 | -contents 157 | DIFF 158 | end 159 | 160 | it "diffs an added file" do 161 | write_file "another.txt", <<~FILE 162 | hello 163 | FILE 164 | jit_cmd "add", "." 165 | 166 | assert_diff_cached <<~DIFF 167 | diff --git a/another.txt b/another.txt 168 | new file mode 100644 169 | index 0000000..ce01362 170 | --- /dev/null 171 | +++ b/another.txt 172 | @@ -0,0 +1,1 @@ 173 | +hello 174 | DIFF 175 | end 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /test/command/commit_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "command_helper" 3 | 4 | require "rev_list" 5 | 6 | describe Command::Commit do 7 | include CommandHelper 8 | 9 | describe "committing to branches" do 10 | before do 11 | ["first", "second", "third"].each do |message| 12 | write_file "file.txt", message 13 | jit_cmd "add", "." 14 | commit message 15 | end 16 | 17 | jit_cmd "branch", "topic" 18 | jit_cmd "checkout", "topic" 19 | end 20 | 21 | def commit_change(content) 22 | write_file "file.txt", content 23 | jit_cmd "add", "." 24 | commit content 25 | end 26 | 27 | describe "on a branch" do 28 | it "advances a branch pointer" do 29 | head_before = repo.refs.read_ref("HEAD") 30 | 31 | commit_change "change" 32 | 33 | head_after = repo.refs.read_ref("HEAD") 34 | branch_after = repo.refs.read_ref("topic") 35 | 36 | refute_equal head_before, head_after 37 | assert_equal head_after, branch_after 38 | 39 | assert_equal head_before, 40 | resolve_revision("@^") 41 | end 42 | end 43 | 44 | describe "with a detached HEAD" do 45 | before do 46 | jit_cmd "checkout", "@" 47 | end 48 | 49 | it "advances HEAD" do 50 | head_before = repo.refs.read_ref("HEAD") 51 | commit_change "change" 52 | head_after = repo.refs.read_ref("HEAD") 53 | 54 | refute_equal head_before, head_after 55 | end 56 | 57 | it "does not advance the detached branch" do 58 | branch_before = repo.refs.read_ref("topic") 59 | commit_change "change" 60 | branch_after = repo.refs.read_ref("topic") 61 | 62 | assert_equal branch_before, branch_after 63 | end 64 | 65 | it "leaves HEAD a commit ahead of the branch" do 66 | commit_change "change" 67 | 68 | assert_equal repo.refs.read_ref("topic"), 69 | resolve_revision("@^") 70 | end 71 | end 72 | 73 | describe "with concurrent branches" do 74 | before do 75 | jit_cmd "branch", "fork", "@^" 76 | end 77 | 78 | it "advances the branches from a shared parent" do 79 | commit_change "A" 80 | commit_change "B" 81 | 82 | jit_cmd "checkout", "fork" 83 | commit_change "C" 84 | 85 | refute_equal resolve_revision("topic"), 86 | resolve_revision("fork") 87 | 88 | assert_equal resolve_revision("topic~3"), 89 | resolve_revision("fork^") 90 | end 91 | end 92 | end 93 | 94 | describe "configuring an author" do 95 | before do 96 | jit_cmd "config", "user.name", "A. N. User" 97 | jit_cmd "config", "user.email", "user@example.com" 98 | end 99 | 100 | it "uses the author information from the config" do 101 | write_file "file.txt", "1" 102 | jit_cmd "add", "." 103 | commit "first", nil, false 104 | 105 | head = load_commit("@") 106 | assert_equal "A. N. User", head.author.name 107 | assert_equal "user@example.com", head.author.email 108 | end 109 | end 110 | 111 | describe "reusing messages" do 112 | before do 113 | write_file "file.txt", "1" 114 | jit_cmd "add", "." 115 | commit "first" 116 | end 117 | 118 | it "uses the message from another commit" do 119 | write_file "file.txt", "2" 120 | jit_cmd "add", "." 121 | jit_cmd "commit", "-C", "@" 122 | 123 | revs = RevList.new(repo, ["HEAD"]) 124 | assert_equal ["first", "first"], revs.map { |commit| commit.message.strip } 125 | end 126 | end 127 | 128 | describe "amending commits" do 129 | before do 130 | ["first", "second", "third"].each do |message| 131 | write_file "file.txt", message 132 | jit_cmd "add", "." 133 | commit message 134 | end 135 | end 136 | 137 | it "replaces the last commit's message" do 138 | stub_editor("third [amended]\n") { jit_cmd "commit", "--amend" } 139 | revs = RevList.new(repo, ["HEAD"]) 140 | 141 | assert_equal ["third [amended]", "second", "first"], 142 | revs.map { |commit| commit.message.strip } 143 | end 144 | 145 | it "replaces the last commit's tree" do 146 | write_file "another.txt", "1" 147 | jit_cmd "add", "another.txt" 148 | jit_cmd "commit", "--amend" 149 | 150 | commit = load_commit("HEAD") 151 | diff = repo.database.tree_diff(commit.parent, commit.oid) 152 | 153 | assert_equal ["another.txt", "file.txt"], 154 | diff.keys.map(&:to_s).sort 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /test/command/status_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "command_helper" 3 | 4 | describe Command::Status do 5 | include CommandHelper 6 | 7 | def assert_status(output) 8 | jit_cmd "status", "--porcelain" 9 | assert_stdout(output) 10 | end 11 | 12 | it "lists files as untracked if they are not in the index" do 13 | write_file "committed.txt", "" 14 | jit_cmd "add", "." 15 | commit "commit message" 16 | 17 | write_file "file.txt", "" 18 | 19 | assert_status <<~STATUS 20 | ?? file.txt 21 | STATUS 22 | end 23 | 24 | it "lists untracked files in name order" do 25 | write_file "file.txt", "" 26 | write_file "another.txt", "" 27 | 28 | assert_status <<~STATUS 29 | ?? another.txt 30 | ?? file.txt 31 | STATUS 32 | end 33 | 34 | it "lists untracked directories, not their contents" do 35 | write_file "file.txt", "" 36 | write_file "dir/another.txt", "" 37 | 38 | assert_status <<~STATUS 39 | ?? dir/ 40 | ?? file.txt 41 | STATUS 42 | end 43 | 44 | it "lists untracked files inside tracked directories" do 45 | write_file "a/b/inner.txt", "" 46 | jit_cmd "add", "." 47 | commit "commit message" 48 | 49 | write_file "a/outer.txt", "" 50 | write_file "a/b/c/file.txt", "" 51 | 52 | assert_status <<~STATUS 53 | ?? a/b/c/ 54 | ?? a/outer.txt 55 | STATUS 56 | end 57 | 58 | it "does not list empty untracked directories" do 59 | mkdir "outer" 60 | 61 | assert_status "" 62 | end 63 | 64 | it "lists untracked directories that indirectly contain files" do 65 | write_file "outer/inner/file.txt", "" 66 | 67 | assert_status <<~STATUS 68 | ?? outer/ 69 | STATUS 70 | end 71 | 72 | describe "index/workspace changes" do 73 | before do 74 | write_file "1.txt", "one" 75 | write_file "a/2.txt", "two" 76 | write_file "a/b/3.txt", "three" 77 | 78 | jit_cmd "add", "." 79 | commit "commit message" 80 | end 81 | 82 | it "prints nothing when no files are changed" do 83 | assert_status "" 84 | end 85 | 86 | it "reports files with modified contents" do 87 | write_file "1.txt", "changed" 88 | write_file "a/2.txt", "modified" 89 | 90 | assert_status <<~STATUS 91 | \ M 1.txt 92 | \ M a/2.txt 93 | STATUS 94 | end 95 | 96 | it "reports modified files with unchanged size" do 97 | write_file "a/b/3.txt", "hello" 98 | 99 | assert_status <<~STATUS 100 | \ M a/b/3.txt 101 | STATUS 102 | end 103 | 104 | it "reports files with changed modes" do 105 | make_executable "a/2.txt" 106 | 107 | assert_status <<~STATUS 108 | \ M a/2.txt 109 | STATUS 110 | end 111 | 112 | it "prints nothing if a file is touched" do 113 | touch "1.txt" 114 | 115 | assert_status "" 116 | end 117 | 118 | it "reports deleted files" do 119 | delete "a/2.txt" 120 | 121 | assert_status <<~STATUS 122 | \ D a/2.txt 123 | STATUS 124 | end 125 | 126 | it "reports files in deleted directories" do 127 | delete "a" 128 | 129 | assert_status <<~STATUS 130 | \ D a/2.txt 131 | \ D a/b/3.txt 132 | STATUS 133 | end 134 | end 135 | 136 | describe "head/index changes" do 137 | before do 138 | write_file "1.txt", "one" 139 | write_file "a/2.txt", "two" 140 | write_file "a/b/3.txt", "three" 141 | 142 | jit_cmd "add", "." 143 | commit "first commit" 144 | end 145 | 146 | it "reports a file added to a tracked directory" do 147 | write_file "a/4.txt", "four" 148 | jit_cmd "add", "." 149 | 150 | assert_status <<~STATUS 151 | A a/4.txt 152 | STATUS 153 | end 154 | 155 | it "reports a file added to an utracked directory" do 156 | write_file "d/e/5.txt", "five" 157 | jit_cmd "add", "." 158 | 159 | assert_status <<~STATUS 160 | A d/e/5.txt 161 | STATUS 162 | end 163 | 164 | it "reports modified modes" do 165 | make_executable "1.txt" 166 | jit_cmd "add", "." 167 | 168 | assert_status <<~STATUS 169 | M 1.txt 170 | STATUS 171 | end 172 | 173 | it "reports modified contents" do 174 | write_file "a/b/3.txt", "changed" 175 | jit_cmd "add", "." 176 | 177 | assert_status <<~STATUS 178 | M a/b/3.txt 179 | STATUS 180 | end 181 | 182 | it "reports deleted files" do 183 | delete "1.txt" 184 | delete ".git/index" 185 | jit_cmd "add", "." 186 | 187 | assert_status <<~STATUS 188 | D 1.txt 189 | STATUS 190 | end 191 | 192 | it "reports all deleted files inside directories" do 193 | delete "a" 194 | delete ".git/index" 195 | jit_cmd "add", "." 196 | 197 | assert_status <<~STATUS 198 | D a/2.txt 199 | D a/b/3.txt 200 | STATUS 201 | end 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /lib/command/log.rb: -------------------------------------------------------------------------------- 1 | require_relative "./base" 2 | require_relative "./shared/print_diff" 3 | require_relative "../rev_list" 4 | 5 | module Command 6 | class Log < Base 7 | 8 | include PrintDiff 9 | 10 | def define_options 11 | @rev_list_opts = {} 12 | @parser.on("--all") { @rev_list_opts[:all] = true } 13 | @parser.on("--branches") { @rev_list_opts[:branches] = true } 14 | @parser.on("--remotes") { @rev_list_opts[:remotes] = true } 15 | 16 | @options[:decorate] = "auto" 17 | @options[:abbrev] = :auto 18 | @options[:format] = "medium" 19 | @options[:patch] = false 20 | 21 | define_print_diff_options 22 | 23 | @parser.on "--decorate[=]" do |format| 24 | @options[:decorate] = format || "short" 25 | end 26 | 27 | @parser.on "--no-decorate" do 28 | @options[:decorate] = "no" 29 | end 30 | 31 | @parser.on "--[no-]abbrev-commit" do |value| 32 | @options[:abbrev] = value 33 | end 34 | 35 | @parser.on "--pretty=", "--format=" do |format| 36 | @options[:format] = format 37 | end 38 | 39 | @parser.on "--oneline" do 40 | @options[:abbrev] = true if @options[:abbrev] == :auto 41 | @options[:format] = "oneline" 42 | end 43 | 44 | @parser.on "--cc" do 45 | @options[:combined] = @options[:patch] = true 46 | end 47 | end 48 | 49 | def run 50 | setup_pager 51 | 52 | @reverse_refs = repo.refs.reverse_refs 53 | @current_ref = repo.refs.current_ref 54 | 55 | @rev_list = ::RevList.new(repo, @args, @rev_list_opts) 56 | @rev_list.each { |commit| show_commit(commit) } 57 | 58 | exit 0 59 | end 60 | 61 | private 62 | 63 | def blank_line 64 | return if @options[:format] == "oneline" 65 | puts "" if defined? @blank_line 66 | @blank_line = true 67 | end 68 | 69 | def show_commit(commit) 70 | case @options[:format] 71 | when "medium" then show_commit_medium(commit) 72 | when "oneline" then show_commit_oneline(commit) 73 | end 74 | 75 | show_patch(commit) 76 | end 77 | 78 | def show_commit_medium(commit) 79 | author = commit.author 80 | 81 | blank_line 82 | puts fmt(:yellow, "commit #{ abbrev(commit) }") + decorate(commit) 83 | 84 | if commit.merge? 85 | oids = commit.parents.map { |oid| repo.database.short_oid(oid) } 86 | puts "Merge: #{ oids.join(" ") }" 87 | end 88 | 89 | puts "Author: #{ author.name } <#{ author.email }>" 90 | puts "Date: #{ author.readable_time }" 91 | blank_line 92 | commit.message.each_line { |line| puts " #{ line }" } 93 | end 94 | 95 | def show_commit_oneline(commit) 96 | id = fmt(:yellow, abbrev(commit)) + decorate(commit) 97 | puts "#{ id } #{ commit.title_line }" 98 | end 99 | 100 | def abbrev(commit) 101 | if @options[:abbrev] == true 102 | repo.database.short_oid(commit.oid) 103 | else 104 | commit.oid 105 | end 106 | end 107 | 108 | def decorate(commit) 109 | case @options[:decorate] 110 | when "auto" then return "" unless @isatty 111 | when "no" then return "" 112 | end 113 | 114 | refs = @reverse_refs[commit.oid] 115 | return "" if refs.empty? 116 | 117 | head, refs = refs.partition { |ref| ref.head? and not @current_ref.head? } 118 | names = refs.map { |ref| decoration_name(head.first, ref) } 119 | 120 | fmt(:yellow, " (") + names.join(fmt(:yellow, ", ")) + fmt(:yellow, ")") 121 | end 122 | 123 | def decoration_name(head, ref) 124 | case @options[:decorate] 125 | when "short", "auto" then name = ref.short_name 126 | when "full" then name = ref.path 127 | end 128 | 129 | name = fmt(ref_color(ref), name) 130 | 131 | if head and ref == @current_ref 132 | name = fmt(ref_color(head), "#{ head.path } -> #{ name }") 133 | end 134 | 135 | name 136 | end 137 | 138 | def ref_color(ref) 139 | return [:bold, :cyan] if ref.head? 140 | return [:bold, :green] if ref.branch? 141 | return [:bold, :red] if ref.remote? 142 | end 143 | 144 | def show_patch(commit) 145 | return unless @options[:patch] 146 | return show_merge_patch(commit) if commit.merge? 147 | 148 | blank_line 149 | print_commit_diff(commit.parent, commit.oid, @rev_list) 150 | end 151 | 152 | def show_merge_patch(commit) 153 | return unless @options[:combined] 154 | 155 | diffs = commit.parents.map { |oid| @rev_list.tree_diff(oid, commit.oid) } 156 | 157 | paths = diffs.first.keys.select do |path| 158 | diffs.drop(1).all? { |diff| diff.has_key?(path) } 159 | end 160 | 161 | blank_line 162 | 163 | paths.each do |path| 164 | parents = diffs.map { |diff| from_entry(path, diff[path][0]) } 165 | child = from_entry(path, diffs.first[path][1]) 166 | 167 | print_combined_diff(parents, child) 168 | end 169 | end 170 | 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /lib/repository/migration.rb: -------------------------------------------------------------------------------- 1 | require "set" 2 | require_relative "./inspector" 3 | 4 | class Repository 5 | class Migration 6 | 7 | Conflict = Class.new(StandardError) 8 | 9 | MESSAGES = { 10 | :stale_file => [ 11 | "Your local changes to the following files would be overwritten by checkout:", 12 | "Please commit your changes or stash them before you switch branches." 13 | ], 14 | :stale_directory => [ 15 | "Updating the following directories would lose untracked files in them:", 16 | "\n" 17 | ], 18 | :untracked_overwritten => [ 19 | "The following untracked working tree files would be overwritten by checkout:", 20 | "Please move or remove them before you switch branches." 21 | ], 22 | :untracked_removed => [ 23 | "The following untracked working tree files would be removed by checkout:", 24 | "Please move or remove them before you switch branches." 25 | ] 26 | } 27 | 28 | attr_reader :changes, :mkdirs, :rmdirs, :errors 29 | 30 | def initialize(repository, tree_diff) 31 | @repo = repository 32 | @diff = tree_diff 33 | 34 | @inspector = Inspector.new(repository) 35 | 36 | @changes = { :create => [], :update => [], :delete => [] } 37 | @mkdirs = Set.new 38 | @rmdirs = Set.new 39 | @errors = [] 40 | 41 | @conflicts = { 42 | :stale_file => SortedSet.new, 43 | :stale_directory => SortedSet.new, 44 | :untracked_overwritten => SortedSet.new, 45 | :untracked_removed => SortedSet.new 46 | } 47 | end 48 | 49 | def apply_changes 50 | plan_changes 51 | update_workspace 52 | update_index 53 | end 54 | 55 | def blob_data(oid) 56 | @repo.database.load(oid).data 57 | end 58 | 59 | private 60 | 61 | def plan_changes 62 | @diff.each do |path, (old_item, new_item)| 63 | check_for_conflict(path, old_item, new_item) 64 | record_change(path, old_item, new_item) 65 | end 66 | 67 | collect_errors 68 | end 69 | 70 | def update_workspace 71 | @repo.workspace.apply_migration(self) 72 | end 73 | 74 | def update_index 75 | @changes[:delete].each do |path, _| 76 | @repo.index.remove(path) 77 | end 78 | 79 | [:create, :update].each do |action| 80 | @changes[action].each do |path, entry| 81 | stat = @repo.workspace.stat_file(path) 82 | @repo.index.add(path, entry.oid, stat) 83 | end 84 | end 85 | end 86 | 87 | def record_change(path, old_item, new_item) 88 | if old_item == nil 89 | @mkdirs.merge(path.dirname.descend) 90 | action = :create 91 | elsif new_item == nil 92 | @rmdirs.merge(path.dirname.descend) 93 | action = :delete 94 | else 95 | @mkdirs.merge(path.dirname.descend) 96 | action = :update 97 | end 98 | @changes[action].push([path, new_item]) 99 | end 100 | 101 | def check_for_conflict(path, old_item, new_item) 102 | entry = @repo.index.entry_for_path(path) 103 | 104 | if index_differs_from_trees(entry, old_item, new_item) 105 | @conflicts[:stale_file].add(path.to_s) 106 | return 107 | end 108 | 109 | stat = @repo.workspace.stat_file(path) 110 | type = get_error_type(stat, entry, new_item) 111 | 112 | if stat == nil 113 | parent = untracked_parent(path) 114 | @conflicts[type].add(entry ? path.to_s : parent.to_s) if parent 115 | 116 | elsif stat.file? 117 | changed = @inspector.compare_index_to_workspace(entry, stat) 118 | @conflicts[type].add(path.to_s) if changed 119 | 120 | elsif stat.directory? 121 | trackable = @inspector.trackable_file?(path, stat) 122 | @conflicts[type].add(path.to_s) if trackable 123 | end 124 | end 125 | 126 | def get_error_type(stat, entry, item) 127 | if entry 128 | :stale_file 129 | elsif stat&.directory? 130 | :stale_directory 131 | elsif item 132 | :untracked_overwritten 133 | else 134 | :untracked_removed 135 | end 136 | end 137 | 138 | def index_differs_from_trees(entry, old_item, new_item) 139 | @inspector.compare_tree_to_index(old_item, entry) and 140 | @inspector.compare_tree_to_index(new_item, entry) 141 | end 142 | 143 | def untracked_parent(path) 144 | path.dirname.ascend.find do |parent| 145 | next if parent.to_s == "." 146 | 147 | parent_stat = @repo.workspace.stat_file(parent) 148 | next unless parent_stat&.file? 149 | 150 | @inspector.trackable_file?(parent, parent_stat) 151 | end 152 | end 153 | 154 | def collect_errors 155 | @conflicts.each do |type, paths| 156 | next if paths.empty? 157 | 158 | lines = paths.map { |name| "\t#{ name }" } 159 | header, footer = MESSAGES.fetch(type) 160 | 161 | @errors.push([header, *lines, footer].join("\n")) 162 | end 163 | 164 | raise Conflict unless @errors.empty? 165 | end 166 | 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /lib/command/shared/write_commit.rb: -------------------------------------------------------------------------------- 1 | module Command 2 | module WriteCommit 3 | 4 | CONFLICT_MESSAGE = <<~MSG 5 | hint: Fix them up in the work tree, and then use 'jit add/rm ' 6 | hint: as appropriate to mark resolution and make a commit. 7 | fatal: Exiting because of an unresolved conflict. 8 | MSG 9 | 10 | MERGE_NOTES = <<~MSG 11 | 12 | It looks like you may be committing a merge. 13 | If this is not correct, please remove the file 14 | \t.git/MERGE_HEAD 15 | and try again. 16 | MSG 17 | 18 | CHERRY_PICK_NOTES = <<~MSG 19 | 20 | It looks like you may be committing a cherry-pick. 21 | If this is not correct, please remove the file 22 | \t.git/CHERRY_PICK_HEAD 23 | and try again. 24 | MSG 25 | 26 | def define_write_commit_options 27 | @options[:edit] = :auto 28 | @parser.on("-e", "--[no-]edit") { |value| @options[:edit] = value } 29 | 30 | @parser.on "-m ", "--message=" do |message| 31 | @options[:message] = message 32 | @options[:edit] = false if @options[:edit] == :auto 33 | end 34 | 35 | @parser.on "-F ", "--file=" do |file| 36 | @options[:file] = expanded_pathname(file) 37 | @options[:edit] = false if @options[:edit] == :auto 38 | end 39 | end 40 | 41 | def read_message 42 | if @options.has_key?(:message) 43 | "#{ @options[:message] }\n" 44 | elsif @options.has_key?(:file) 45 | File.read(@options[:file]) 46 | end 47 | end 48 | 49 | def write_commit(parents, message) 50 | unless message 51 | @stderr.puts "Aborting commit due to empty commit message." 52 | exit 1 53 | end 54 | 55 | tree = write_tree 56 | author = current_author 57 | commit = Database::Commit.new(parents, tree.oid, author, author, message) 58 | 59 | repo.database.store(commit) 60 | repo.refs.update_head(commit.oid) 61 | 62 | commit 63 | end 64 | 65 | def write_tree 66 | root = Database::Tree.build(repo.index.each_entry) 67 | root.traverse { |tree| repo.database.store(tree) } 68 | root 69 | end 70 | 71 | def current_author 72 | config_name = repo.config.get(["user", "name"]) 73 | config_email = repo.config.get(["user", "email"]) 74 | 75 | name = @env.fetch("GIT_AUTHOR_NAME", config_name) 76 | email = @env.fetch("GIT_AUTHOR_EMAIL", config_email) 77 | 78 | Database::Author.new(name, email, Time.now) 79 | end 80 | 81 | def print_commit(commit) 82 | ref = repo.refs.current_ref 83 | info = ref.head? ? "detached HEAD" : ref.short_name 84 | oid = repo.database.short_oid(commit.oid) 85 | 86 | info.concat(" (root-commit)") unless commit.parent 87 | info.concat(" #{ oid }") 88 | 89 | puts "[#{ info }] #{ commit.title_line }" 90 | end 91 | 92 | def pending_commit 93 | @pending_commit ||= repo.pending_commit 94 | end 95 | 96 | def resume_merge(type) 97 | case type 98 | when :merge then write_merge_commit 99 | when :cherry_pick then write_cherry_pick_commit 100 | when :revert then write_revert_commit 101 | end 102 | 103 | exit 0 104 | end 105 | 106 | def write_merge_commit 107 | handle_conflicted_index 108 | 109 | parents = [repo.refs.read_head, pending_commit.merge_oid] 110 | message = compose_merge_message(MERGE_NOTES) 111 | write_commit(parents, message) 112 | 113 | pending_commit.clear(:merge) 114 | end 115 | 116 | def write_cherry_pick_commit 117 | handle_conflicted_index 118 | 119 | parents = [repo.refs.read_head] 120 | message = compose_merge_message(CHERRY_PICK_NOTES) 121 | 122 | pick_oid = pending_commit.merge_oid(:cherry_pick) 123 | commit = repo.database.load(pick_oid) 124 | 125 | picked = Database::Commit.new(parents, write_tree.oid, 126 | commit.author, current_author, 127 | message) 128 | 129 | repo.database.store(picked) 130 | repo.refs.update_head(picked.oid) 131 | pending_commit.clear(:cherry_pick) 132 | end 133 | 134 | def write_revert_commit 135 | handle_conflicted_index 136 | 137 | parents = [repo.refs.read_head] 138 | message = compose_merge_message 139 | write_commit(parents, message) 140 | 141 | pending_commit.clear(:revert) 142 | end 143 | 144 | def compose_merge_message(notes = nil) 145 | edit_file(commit_message_path) do |editor| 146 | editor.puts(pending_commit.merge_message) 147 | editor.note(notes) if notes 148 | editor.puts("") 149 | editor.note(Commit::COMMIT_NOTES) 150 | end 151 | end 152 | 153 | def commit_message_path 154 | repo.git_path.join("COMMIT_EDITMSG") 155 | end 156 | 157 | def handle_conflicted_index 158 | return unless repo.index.conflict? 159 | 160 | message = "Committing is not possible because you have unmerged files" 161 | @stderr.puts "error: #{ message }." 162 | @stderr.puts CONFLICT_MESSAGE 163 | exit 128 164 | end 165 | 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /lib/command/push.rb: -------------------------------------------------------------------------------- 1 | require_relative "./base" 2 | require_relative "./shared/fast_forward" 3 | require_relative "./shared/remote_client" 4 | require_relative "./shared/send_objects" 5 | require_relative "../remotes" 6 | require_relative "../revision" 7 | 8 | module Command 9 | class Push < Base 10 | 11 | include FastForward 12 | include RemoteClient 13 | include SendObjects 14 | 15 | CAPABILITIES = ["report-status"] 16 | RECEIVE_PACK = "git-receive-pack" 17 | 18 | UNPACK_LINE = /^unpack (.+)$/ 19 | UPDATE_LINE = /^(ok|ng) (\S+)(.*)$/ 20 | 21 | def define_options 22 | @parser.on("-f", "--force") { @options[:force] = true } 23 | 24 | @parser.on "--receive-pack=" do |receiver| 25 | @options[:receiver] = receiver 26 | end 27 | end 28 | 29 | def run 30 | configure 31 | start_agent("push", @receiver, @push_url, CAPABILITIES) 32 | 33 | recv_references 34 | send_update_requests 35 | send_objects 36 | print_summary 37 | recv_report_status 38 | 39 | exit (@errors.empty? ? 0 : 1) 40 | end 41 | 42 | private 43 | 44 | def configure 45 | current_branch = repo.refs.current_ref.short_name 46 | branch_remote = repo.config.get(["branch", current_branch, "remote"]) 47 | branch_merge = repo.config.get(["branch", current_branch, "merge"]) 48 | 49 | name = @args.fetch(0, branch_remote || Remotes::DEFAULT_REMOTE) 50 | remote = repo.remotes.get(name) 51 | 52 | @push_url = remote&.push_url || @args[0] 53 | @fetch_specs = remote&.fetch_specs || [] 54 | @receiver = @options[:receiver] || remote&.receiver || RECEIVE_PACK 55 | 56 | if @args.size > 1 57 | @push_specs = @args.drop(1) 58 | elsif branch_merge 59 | spec = Remotes::Refspec.new(current_branch, branch_merge, false) 60 | @push_specs = [spec.to_s] 61 | else 62 | @push_specs = remote&.push_specs 63 | end 64 | end 65 | 66 | def send_update_requests 67 | @updates = {} 68 | @errors = [] 69 | 70 | local_refs = repo.refs.list_all_refs.map(&:path).sort 71 | targets = Remotes::Refspec.expand(@push_specs, local_refs) 72 | 73 | targets.each do |target, (source, forced)| 74 | select_update(target, source, forced) 75 | end 76 | 77 | @updates.each { |ref, (*, old, new)| send_update(ref, old, new) } 78 | @conn.send_packet(nil) 79 | end 80 | 81 | def select_update(target, source, forced) 82 | return select_deletion(target) unless source 83 | 84 | old_oid = @remote_refs[target] 85 | new_oid = Revision.new(repo, source).resolve 86 | 87 | return if old_oid == new_oid 88 | 89 | ff_error = fast_forward_error(old_oid, new_oid) 90 | 91 | if @options[:force] or forced or ff_error == nil 92 | @updates[target] = [source, ff_error, old_oid, new_oid] 93 | else 94 | @errors.push([[source, target], ff_error]) 95 | end 96 | end 97 | 98 | def select_deletion(target) 99 | if @conn.capable?("delete-refs") 100 | @updates[target] = [nil, nil, @remote_refs[target], nil] 101 | else 102 | @errors.push([[nil, target], "remote does not support deleting refs"]) 103 | end 104 | end 105 | 106 | def send_update(ref, old_oid, new_oid) 107 | old_oid = nil_to_zero(old_oid) 108 | new_oid = nil_to_zero(new_oid) 109 | 110 | @conn.send_packet("#{ old_oid } #{ new_oid } #{ ref }") 111 | end 112 | 113 | def nil_to_zero(oid) 114 | oid == nil ? ZERO_OID : oid 115 | end 116 | 117 | def send_objects 118 | revs = @updates.values.map(&:last).compact 119 | return if revs.empty? 120 | 121 | revs += @remote_refs.values.map { |oid| "^#{ oid }" } 122 | 123 | send_packed_objects(revs) 124 | end 125 | 126 | def print_summary 127 | if @updates.empty? and @errors.empty? 128 | @stderr.puts "Everything up-to-date" 129 | else 130 | @stderr.puts "To #{ @push_url }" 131 | @errors.each { |ref_names, error| report_ref_update(ref_names, error) } 132 | end 133 | end 134 | 135 | def recv_report_status 136 | return unless @conn.capable?("report-status") and not @updates.empty? 137 | 138 | unpack_result = UNPACK_LINE.match(@conn.recv_packet)[1] 139 | 140 | unless unpack_result == "ok" 141 | @stderr.puts "error: remote unpack failed: #{ unpack_result }" 142 | end 143 | 144 | @conn.recv_until(nil) { |line| handle_status(line) } 145 | end 146 | 147 | def handle_status(line) 148 | return unless match = UPDATE_LINE.match(line) 149 | 150 | status = match[1] 151 | ref = match[2] 152 | error = (status == "ok") ? nil : match[3].strip 153 | 154 | @errors.push([ref, error]) if error 155 | report_update(ref, error) 156 | 157 | targets = Remotes::Refspec.expand(@fetch_specs, [ref]) 158 | 159 | targets.each do |local_ref, (remote_ref, _)| 160 | new_oid = @updates[remote_ref].last 161 | repo.refs.update_ref(local_ref, new_oid) unless error 162 | end 163 | end 164 | 165 | def report_update(target, error) 166 | source, ff_error, old_oid, new_oid = @updates[target] 167 | ref_names = [source, target] 168 | report_ref_update(ref_names, error, old_oid, new_oid, ff_error == nil) 169 | end 170 | 171 | end 172 | end 173 | --------------------------------------------------------------------------------