diff --git a/.rubocop.yml b/.rubocop.yml index 4d150d7..d27eb60 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -12,7 +12,7 @@ AllCops: Layout/LineLength: Max: 120 -# The DSL for RSpec and the gemspec file make it very hard to limit block length: +# The DSL for RSpec makes it very hard to limit block length: Metrics/BlockLength: Exclude: - "spec/**/*_spec.rb" diff --git a/lib/ruby_git.rb b/lib/ruby_git.rb index c35ea87..c278543 100644 --- a/lib/ruby_git.rb +++ b/lib/ruby_git.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true -require 'ruby_git/version' +require 'ruby_git/error' require 'ruby_git/file_helpers' require 'ruby_git/git_binary' +require 'ruby_git/version' +require 'ruby_git/worktree' # RubyGit is an object-oriented wrapper for the `git` command line tool for # working with Worktrees and Repositories. It tries to make more sense out @@ -24,4 +26,79 @@ module RubyGit def self.git (@git ||= RubyGit::GitBinary.new) end + + # Create an empty Git repository under the root worktree `path` + # + # If the repository already exists, it will not be overwritten. + # + # @see https://git-scm.com/docs/git-init git-init + # + # @example + # worktree = Worktree.init(worktree_path) + # + # @param [String] worktree_path the root path of a worktree + # + # @raise [RubyGit::Error] if worktree_path is not a directory + # + # @return [RubyGit::Worktree] the worktree whose root is at `path` + # + def self.init(worktree_path) + RubyGit::Worktree.init(worktree_path) + end + + # Open an existing Git worktree that contains worktree_path + # + # @see https://git-scm.com/docs/git-open git-open + # + # @example + # worktree = Worktree.open(worktree_path) + # + # @param [String] worktree_path the root path of a worktree + # + # @raise [RubyGit::Error] if `worktree_path` does not exist, is not a directory, or is not within a Git worktree. + # + # @return [RubyGit::Worktree] the worktree that contains `worktree_path` + # + def self.open(worktree_path) + RubyGit::Worktree.open(worktree_path) + end + + # Copy the remote repository and checkout the default branch + # + # Clones the repository referred to by `repository_url` into a newly created + # directory, creates remote-tracking branches for each branch in the cloned repository, + # and checks out the default branch in the worktree whose root directory is `to_path`. + # + # @see https://git-scm.com/docs/git-clone git-clone + # + # @example Using default for Worktree path + # FileUtils.pwd + # => "/Users/jsmith" + # worktree = Worktree.clone('https://github.com/main-branch/ruby_git.git') + # worktree.path + # => "/Users/jsmith/ruby_git" + # + # @example Using a specified worktree_path + # FileUtils.pwd + # => "/Users/jsmith" + # worktree_path = '/tmp/project' + # worktree = Worktree.clone('https://github.com/main-branch/ruby_git.git', to_path: worktree_path) + # worktree.path + # => "/tmp/project" + # + # @param [String] repository_url a reference to a Git repository + # + # @param [String] to_path where to put the checked out worktree once the repository is cloned + # + # `to_path` will be created if it does not exist. An error is raised if `to_path` exists and + # not an empty directory. + # + # @raise [RubyGit::Error] if (1) `repository_url` is not valid or does not point to a valid repository OR + # (2) `to_path` is not an empty directory. + # + # @return [RubyGit::Worktree] the worktree checked out from the cloned repository + # + def self.clone(repository_url, to_path: '') + RubyGit::Worktree.clone(repository_url, to_path: to_path) + end end diff --git a/lib/ruby_git/error.rb b/lib/ruby_git/error.rb new file mode 100644 index 0000000..7a7f551 --- /dev/null +++ b/lib/ruby_git/error.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module RubyGit + # Errors specific to RubyGit raise RubyGit::Error + # + class Error < StandardError + end +end diff --git a/lib/ruby_git/git_binary.rb b/lib/ruby_git/git_binary.rb index 57a5857..b17ca87 100644 --- a/lib/ruby_git/git_binary.rb +++ b/lib/ruby_git/git_binary.rb @@ -11,8 +11,8 @@ class GitBinary # @example # GitBinary.new # - def initialize - @path = nil + def initialize(path = nil) + @path = Pathname.new(path) unless path.nil? end # Sets the path to the git binary @@ -86,5 +86,21 @@ def version version = output[/\d+\.\d+(\.\d+)+/] version.split('.').collect(&:to_i) end + + # Return the path as a string + # + # @example + # git = RubyGit::GitBinary.new('/usr/bin/git') + # git.to_s + # => '/usr/bin/git' + # + # @return [String] the path to the binary + # + # @raise [RuntimeError] if path was not set via `path=` and either PATH is not set + # or git was not found on the path. + # + def to_s + path.to_s + end end end diff --git a/lib/ruby_git/worktree.rb b/lib/ruby_git/worktree.rb new file mode 100644 index 0000000..149394e --- /dev/null +++ b/lib/ruby_git/worktree.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'open3' + +module RubyGit + # The Worktree is a directory tree consisting of the checked out files that + # you are currently working on. + # + # Create a new Worktree using {.init}, {.clone}, or {.open}. + # + class Worktree + # The root path of the worktree + # + # @example + # worktree_path = '/Users/James/myproject' + # worktree = Worktree.open(worktree_path) + # worktree.path + # => '/Users/James/myproject' + # + # @return [Pathname] the root path of the worktree + # + attr_reader :path + + # Create an empty Git repository under the root worktree `path` + # + # If the repository already exists, it will not be overwritten. + # + # @see https://git-scm.com/docs/git-init git-init + # + # @example + # worktree = Worktree.init(worktree_path) + # + # @param [String] worktree_path the root path of a worktree + # + # @raise [RubyGit::Error] if worktree_path is not a directory + # + # @return [RubyGit::Worktree] the worktree whose root is at `path` + # + def self.init(worktree_path) + raise RubyGit::Error, "Path '#{worktree_path}' not valid." unless File.directory?(worktree_path) + + command = [RubyGit.git.path.to_s, 'init'] + _out, err, status = Open3.capture3(*command, chdir: worktree_path) + raise RubyGit::Error, err unless status.success? + + Worktree.new(worktree_path) + end + + # Open an existing Git worktree that contains worktree_path + # + # @see https://git-scm.com/docs/git-open git-open + # + # @example + # worktree = Worktree.open(worktree_path) + # + # @param [String] worktree_path the root path of a worktree + # + # @raise [RubyGit::Error] if `worktree_path` does not exist, is not a directory, or is not within a Git worktree. + # + # @return [RubyGit::Worktree] the worktree that contains `worktree_path` + # + def self.open(worktree_path) + new(worktree_path) + end + + # Copy the remote repository and checkout the default branch + # + # Clones the repository referred to by `repository_url` into a newly created + # directory, creates remote-tracking branches for each branch in the cloned repository, + # and checks out the default branch in the worktree whose root directory is `to_path`. + # + # @see https://git-scm.com/docs/git-clone git-clone + # + # @example Using default for Worktree path + # FileUtils.pwd + # => "/Users/jsmith" + # worktree = Worktree.clone('https://github.com/main-branch/ruby_git.git') + # worktree.path + # => "/Users/jsmith/ruby_git" + # + # @example Using a specified worktree_path + # FileUtils.pwd + # => "/Users/jsmith" + # worktree_path = '/tmp/project' + # worktree = Worktree.clone('https://github.com/main-branch/ruby_git.git', to_path: worktree_path) + # worktree.path + # => "/tmp/project" + # + # @param [String] repository_url a reference to a Git repository + # + # @param [String] to_path where to put the checked out worktree once the repository is cloned + # + # `to_path` will be created if it does not exist. An error is raised if `to_path` exists and + # not an empty directory. + # + # @raise [RubyGit::Error] if (1) `repository_url` is not valid or does not point to a valid repository OR + # (2) `to_path` is not an empty directory. + # + # @return [RubyGit::Worktree] the worktree checked out from the cloned repository + # + def self.clone(repository_url, to_path: '') + command = [RubyGit.git.path.to_s, 'clone', '--', repository_url, to_path] + _out, err, status = Open3.capture3(*command) + raise RubyGit::Error, err unless status.success? + + new(to_path) + end + + private + + # Create a Worktree object + # @api private + def initialize(worktree_path) + raise RubyGit::Error, "Path '#{worktree_path}' not valid." unless File.directory?(worktree_path) + + @path = root_path(worktree_path) + end + + # Find the root path of a worktree containing `path` + # + # @raise [RubyGit::Error] if the path is not in a worktree + # + # @return [String] the root path of the worktree containing `path` + # + # @api private + # + def root_path(worktree_path) + command = [RubyGit.git.path.to_s, 'rev-parse', '--show-toplevel'] + out, err, status = Open3.capture3(*command, chdir: worktree_path) + raise RubyGit::Error, err unless status.success? + + out.chomp + end + end +end diff --git a/spec/lib/ruby_git/git_binary_spec.rb b/spec/lib/ruby_git/git_binary_spec.rb index 82dc6e5..d84d92e 100644 --- a/spec/lib/ruby_git/git_binary_spec.rb +++ b/spec/lib/ruby_git/git_binary_spec.rb @@ -3,93 +3,38 @@ require 'tmpdir' RSpec.describe RubyGit::GitBinary do - let(:git_binary) { described_class.new } - - describe '.default_path' do - context 'with no basename' do - subject { described_class.default_path } - let(:dir) { Dir.mktmpdir } - after { FileUtils.rm_rf dir } - - context "when 'git' is not in the path" do - it 'should raise a RuntimeError' do - saved_env = ENV.to_hash - begin - ENV.replace({ 'PATH' => dir }) - expect { subject }.to raise_error(RuntimeError) - ensure - ENV.replace(saved_env) - end - end - end - - context "when 'git' is in the path" do - it 'should return a Pathname to the the first executable file in the PATH whose basename is git' do - saved_env = ENV.to_hash - begin - ENV.replace({ 'PATH' => dir }) - path = Pathname.new(File.join(dir, 'git')) - FileUtils.touch(path) - path.chmod(0o755) - - expect(subject).to be_kind_of(Pathname) - expect(subject).to eq(path) - ensure - ENV.replace(saved_env) - end - end - end - end + context 'with no path in the initializer' do + let(:git_binary) { described_class.new } - context "with basename 'mygit'" do - basename = 'mygit' - subject { described_class.default_path(basename: basename) } + describe '.default_path' do + context 'with no basename' do + subject { described_class.default_path } + let(:dir) { Dir.mktmpdir } + after { FileUtils.rm_rf dir } - context "and '#{basename}' is not in the PATH and 'git' is in the PATH" do - it 'should raise a RuntimeError' do - Dir.mktmpdir do |dir| + context "when 'git' is not in the path" do + it 'should raise a RuntimeError' do saved_env = ENV.to_hash begin ENV.replace({ 'PATH' => dir }) - - path = Pathname.new(File.join(dir, 'git')) - FileUtils.touch(path) - path.chmod(0o755) expect { subject }.to raise_error(RuntimeError) ensure ENV.replace(saved_env) end end end - end - context "and '#{basename}' is in the PATH but not a file" do - it 'should raise a RuntimeError' do - Dir.mktmpdir do |dir| + context "when 'git' is in the path" do + it 'should return a Pathname to the the first executable file in the PATH whose basename is git' do saved_env = ENV.to_hash begin ENV.replace({ 'PATH' => dir }) - path = Pathname.new(File.join(dir, basename)) - FileUtils.mkdir(path) + path = Pathname.new(File.join(dir, 'git')) + FileUtils.touch(path) path.chmod(0o755) - expect { subject }.to raise_error(RuntimeError) - ensure - ENV.replace(saved_env) - end - end - end - end - context "and '#{basename}' is in the PATH but not executable file" do - it 'should raise a RuntimeError' do - Dir.mktmpdir do |dir| - saved_env = ENV.to_hash - begin - ENV.replace({ 'PATH' => dir }) - path = Pathname.new(File.join(dir, basename)) - FileUtils.mkdir(path) - path.chmod(0o666) - expect { subject }.to raise_error(RuntimeError) + expect(subject).to be_kind_of(Pathname) + expect(subject).to eq(path) ensure ENV.replace(saved_env) end @@ -97,17 +42,21 @@ end end - context "and PATHEXT is '.exe;.com'" do - context "and '#{basename}.com' is an executable file in the path" do - it 'should not raise a RuntimeError' do + context "with basename 'mygit'" do + basename = 'mygit' + subject { described_class.default_path(basename: basename) } + + context "and '#{basename}' is not in the PATH and 'git' is in the PATH" do + it 'should raise a RuntimeError' do Dir.mktmpdir do |dir| saved_env = ENV.to_hash begin - ENV.replace({ 'PATH' => dir, 'PATHEXT' => '.com;.exe' }) - path = Pathname.new(File.join(dir, "#{basename}.com")) + ENV.replace({ 'PATH' => dir }) + + path = Pathname.new(File.join(dir, 'git')) FileUtils.touch(path) path.chmod(0o755) - expect { subject }.not_to raise_error + expect { subject }.to raise_error(RuntimeError) ensure ENV.replace(saved_env) end @@ -115,16 +64,16 @@ end end - context "and '#{basename}.exe' is an executable file in the path" do - it 'should not raise a RuntimeError' do + context "and '#{basename}' is in the PATH but not a file" do + it 'should raise a RuntimeError' do Dir.mktmpdir do |dir| saved_env = ENV.to_hash begin - ENV.replace({ 'PATH' => dir, 'PATHEXT' => '.com;.exe' }) - path = Pathname.new(File.join(dir, "#{basename}.exe")) - FileUtils.touch(path) + ENV.replace({ 'PATH' => dir }) + path = Pathname.new(File.join(dir, basename)) + FileUtils.mkdir(path) path.chmod(0o755) - expect { subject }.not_to raise_error + expect { subject }.to raise_error(RuntimeError) ensure ENV.replace(saved_env) end @@ -132,15 +81,15 @@ end end - context "when neither '#{basename}.com' or '#{basename}.exe' are an executable file in the path" do + context "and '#{basename}' is in the PATH but not executable file" do it 'should raise a RuntimeError' do Dir.mktmpdir do |dir| saved_env = ENV.to_hash begin - ENV.replace({ 'PATH' => dir, 'PATHEXT' => '.com;.exe' }) - path = Pathname.new(File.join(dir, 'git.fubar')) - FileUtils.touch(path) - path.chmod(0o755) + ENV.replace({ 'PATH' => dir }) + path = Pathname.new(File.join(dir, basename)) + FileUtils.mkdir(path) + path.chmod(0o666) expect { subject }.to raise_error(RuntimeError) ensure ENV.replace(saved_env) @@ -148,185 +97,252 @@ end end end - end - end - end - describe '#path=' do - subject { described_class.new.path = new_path } - let(:dir) { Dir.mktmpdir } - after { FileUtils.rm_rf dir } + context "and PATHEXT is '.exe;.com'" do + context "and '#{basename}.com' is an executable file in the path" do + it 'should not raise a RuntimeError' do + Dir.mktmpdir do |dir| + saved_env = ENV.to_hash + begin + ENV.replace({ 'PATH' => dir, 'PATHEXT' => '.com;.exe' }) + path = Pathname.new(File.join(dir, "#{basename}.com")) + FileUtils.touch(path) + path.chmod(0o755) + expect { subject }.not_to raise_error + ensure + ENV.replace(saved_env) + end + end + end + end - context 'when given a path that is not convertable to a string' do - let(:new_path) { 1 } - it 'should raise a TypeError' do - expect { subject }.to raise_error(TypeError) - end - end + context "and '#{basename}.exe' is an executable file in the path" do + it 'should not raise a RuntimeError' do + Dir.mktmpdir do |dir| + saved_env = ENV.to_hash + begin + ENV.replace({ 'PATH' => dir, 'PATHEXT' => '.com;.exe' }) + path = Pathname.new(File.join(dir, "#{basename}.exe")) + FileUtils.touch(path) + path.chmod(0o755) + expect { subject }.not_to raise_error + ensure + ENV.replace(saved_env) + end + end + end + end - context 'when given a path that does not exist' do - let(:new_path) { Pathname.new(File.join(dir, 'git')) } - it 'should raise a RuntimeError' do - expect { subject }.to raise_error(RuntimeError) + context "when neither '#{basename}.com' or '#{basename}.exe' are an executable file in the path" do + it 'should raise a RuntimeError' do + Dir.mktmpdir do |dir| + saved_env = ENV.to_hash + begin + ENV.replace({ 'PATH' => dir, 'PATHEXT' => '.com;.exe' }) + path = Pathname.new(File.join(dir, 'git.fubar')) + FileUtils.touch(path) + path.chmod(0o755) + expect { subject }.to raise_error(RuntimeError) + ensure + ENV.replace(saved_env) + end + end + end + end + end end end - context 'when set to a path that is not a file' do - let(:new_path) { Pathname.new(File.join(dir, 'git')) } - it 'should raise a RuntimeError' do - Dir.mktmpdir do |_dir| - FileUtils.mkdir(new_path) - expect { subject }.to raise_error(RuntimeError) + describe '#path=' do + subject { described_class.new.path = new_path } + let(:dir) { Dir.mktmpdir } + after { FileUtils.rm_rf dir } + + context 'when given a path that is not convertable to a string' do + let(:new_path) { 1 } + it 'should raise a TypeError' do + expect { subject }.to raise_error(TypeError) end end - end - context 'when set to a path that is not an executable file' do - let(:new_path) { Pathname.new(File.join(dir, 'git')) } - it 'should raise a RuntimeError' do - Dir.mktmpdir do |_dir| - FileUtils.touch(new_path) - new_path.chmod(0o644) + context 'when given a path that does not exist' do + let(:new_path) { Pathname.new(File.join(dir, 'git')) } + it 'should raise a RuntimeError' do expect { subject }.to raise_error(RuntimeError) end end - end - context 'when set to a path that is an executable file' do - let(:new_path) { Pathname.new(File.join(dir, 'git')) } - it 'should return the path' do - Dir.mktmpdir do |_dir| - FileUtils.touch(new_path) - new_path.chmod(0o755) - expect { subject }.not_to raise_error - expect(subject).to eq(new_path) + context 'when set to a path that is not a file' do + let(:new_path) { Pathname.new(File.join(dir, 'git')) } + it 'should raise a RuntimeError' do + Dir.mktmpdir do |_dir| + FileUtils.mkdir(new_path) + expect { subject }.to raise_error(RuntimeError) + end end end - end - context 'when path is given as a string' do - let(:new_path) { Pathname.new(File.join(dir, 'git')) } - it 'should return a Pathname' do - Dir.mktmpdir do |_dir| - FileUtils.touch(new_path) - FileUtils.chmod(0o755, new_path) - expect { subject }.not_to raise_error - expect(subject).to eq(new_path) - expect(subject).to be_kind_of(Pathname) + context 'when set to a path that is not an executable file' do + let(:new_path) { Pathname.new(File.join(dir, 'git')) } + it 'should raise a RuntimeError' do + Dir.mktmpdir do |_dir| + FileUtils.touch(new_path) + new_path.chmod(0o644) + expect { subject }.to raise_error(RuntimeError) + end end end - end - context 'when set to a path that is a symlink to an executable file' do - let(:new_path) { Pathname.new(File.join(dir, 'symlink_to_git')) } - it 'should not raise an error' do - Dir.mktmpdir do |dir| - actual_path = Pathname.new(File.join(dir, 'git')) - FileUtils.touch(actual_path) - actual_path.chmod(0o755) - FileUtils.ln_s(actual_path, new_path) - expect { subject }.not_to raise_error - expect(subject).to eq(new_path) + context 'when set to a path that is an executable file' do + let(:new_path) { Pathname.new(File.join(dir, 'git')) } + it 'should return the path' do + Dir.mktmpdir do |_dir| + FileUtils.touch(new_path) + new_path.chmod(0o755) + expect { subject }.not_to raise_error + expect(subject).to eq(new_path) + end end end - end - end - describe '#path' do - let(:git_binary) { described_class.new } - subject { git_binary.path } - let(:dir) { Dir.mktmpdir } - after { FileUtils.rm_rf dir } - - context 'when path was not set' do - context 'and git is in the PATH' do - it 'should find the git in the PATH' do - saved_env = ENV.to_hash - begin - ENV.replace({ 'PATH' => dir }) - path = Pathname.new(File.join(dir, 'git')) - FileUtils.touch(path) - FileUtils.chmod(0o755, path) + context 'when path is given as a string' do + let(:new_path) { Pathname.new(File.join(dir, 'git')) } + it 'should return a Pathname' do + Dir.mktmpdir do |_dir| + FileUtils.touch(new_path) + FileUtils.chmod(0o755, new_path) + expect { subject }.not_to raise_error + expect(subject).to eq(new_path) expect(subject).to be_kind_of(Pathname) - expect(subject).to eq(path) - ensure - ENV.replace(saved_env) end end end - context 'and git is not in the PATH' do - it 'should raise a RuntimeError' do - saved_env = ENV.to_hash - begin - ENV.replace({ 'PATH' => dir }) - expect { subject }.to raise_error(RuntimeError) - ensure - ENV.replace(saved_env) + context 'when set to a path that is a symlink to an executable file' do + let(:new_path) { Pathname.new(File.join(dir, 'symlink_to_git')) } + it 'should not raise an error' do + Dir.mktmpdir do |dir| + actual_path = Pathname.new(File.join(dir, 'git')) + FileUtils.touch(actual_path) + actual_path.chmod(0o755) + FileUtils.ln_s(actual_path, new_path) + expect { subject }.not_to raise_error + expect(subject).to eq(new_path) end end end end - context 'when path was set' do - let(:new_path) { Pathname.new(File.join(dir, 'mygit')) } - before do - FileUtils.touch(new_path) - FileUtils.chmod(0o755, new_path) - git_binary.path = new_path - end - it 'should return what path was set to' do - expect(subject).to eq(new_path) + describe '#to_s' do + let(:git_binary) { described_class.new } + subject { git_binary.to_s } + context "with '/usr/bin/git' passed in the initializer" do + let(:git_binary) { described_class.new('/usr/bin/git') } + it { is_expected.to eq('/usr/bin/git') } end end - context 'when path has been set to a file not in the PATH' do - context 'and a different git exists in the PATH' do - it 'should return what path was originally set to' do - directory_in_path = File.join(dir, 'dir1') - FileUtils.mkdir(directory_in_path) - git_in_path = Pathname.new(File.join(directory_in_path, 'git')) - FileUtils.touch(git_in_path) - FileUtils.chmod(0o755, git_in_path) - - directory_not_in_path = File.join(dir, 'dir2') - FileUtils.mkdir(directory_not_in_path) - git_not_in_path = Pathname.new(File.join(directory_not_in_path, 'git')) - FileUtils.touch(git_not_in_path) - FileUtils.chmod(0o755, git_not_in_path) - - saved_env = ENV.to_hash - begin - ENV.replace({ 'PATH' => directory_in_path }) - git_binary.path = git_not_in_path - - expect(subject).to eq(git_not_in_path) - ensure - ENV.replace(saved_env) + describe '#path' do + let(:git_binary) { described_class.new } + subject { git_binary.path } + let(:dir) { Dir.mktmpdir } + after { FileUtils.rm_rf dir } + + context "with '/usr/bin/git' passed in the initializer" do + let(:git_binary) { described_class.new('/usr/bin/git') } + it { is_expected.to eq(Pathname.new('/usr/bin/git')) } + end + + context 'when path was not set' do + context 'and git is in the PATH' do + it 'should find the git in the PATH' do + saved_env = ENV.to_hash + begin + ENV.replace({ 'PATH' => dir }) + path = Pathname.new(File.join(dir, 'git')) + FileUtils.touch(path) + FileUtils.chmod(0o755, path) + expect(subject).to be_kind_of(Pathname) + expect(subject).to eq(path) + ensure + ENV.replace(saved_env) + end + end + end + + context 'and git is not in the PATH' do + it 'should raise a RuntimeError' do + saved_env = ENV.to_hash + begin + ENV.replace({ 'PATH' => dir }) + expect { subject }.to raise_error(RuntimeError) + ensure + ENV.replace(saved_env) + end + end + end + end + + context 'when path was set' do + let(:new_path) { Pathname.new(File.join(dir, 'mygit')) } + before do + FileUtils.touch(new_path) + FileUtils.chmod(0o755, new_path) + git_binary.path = new_path + end + it 'should return what path was set to' do + expect(subject).to eq(new_path) + end + end + + context 'when path has been set to a file not in the PATH' do + context 'and a different git exists in the PATH' do + it 'should return what path was originally set to' do + directory_in_path = File.join(dir, 'dir1') + FileUtils.mkdir(directory_in_path) + git_in_path = Pathname.new(File.join(directory_in_path, 'git')) + FileUtils.touch(git_in_path) + FileUtils.chmod(0o755, git_in_path) + + directory_not_in_path = File.join(dir, 'dir2') + FileUtils.mkdir(directory_not_in_path) + git_not_in_path = Pathname.new(File.join(directory_not_in_path, 'git')) + FileUtils.touch(git_not_in_path) + FileUtils.chmod(0o755, git_not_in_path) + + saved_env = ENV.to_hash + begin + ENV.replace({ 'PATH' => directory_in_path }) + git_binary.path = git_not_in_path + + expect(subject).to eq(git_not_in_path) + ensure + ENV.replace(saved_env) + end end end end end - end - describe '#version' do - subject { git_binary.version } - let(:dir) { Dir.mktmpdir } - after { FileUtils.rm_rf dir } - it 'should return the version returned from --version' do - saved_env = ENV.to_hash - begin - ENV.replace({ 'PATH' => "#{dir}:#{ENV['PATH']}" }) - path = Pathname.new(File.join(dir, 'git')) - File.write(path, <<~SCRIPT) - #!/usr/bin/env sh - if [ "$1" != '--version' ]; then echo '--version required'; exit 1; fi - echo 'git version 10.11.12' - SCRIPT - FileUtils.chmod(0o755, path) - expect(subject).to eq([10, 11, 12]) - ensure - ENV.replace(saved_env) + describe '#version' do + subject { git_binary.version } + let(:dir) { Dir.mktmpdir } + after { FileUtils.rm_rf dir } + it 'should return the version returned from --version' do + saved_env = ENV.to_hash + begin + ENV.replace({ 'PATH' => "#{dir}:#{ENV['PATH']}" }) + path = Pathname.new(File.join(dir, 'git')) + File.write(path, <<~SCRIPT) + #!/usr/bin/env sh + if [ "$1" != '--version' ]; then echo '--version required'; exit 1; fi + echo 'git version 10.11.12' + SCRIPT + FileUtils.chmod(0o755, path) + expect(subject).to eq([10, 11, 12]) + ensure + ENV.replace(saved_env) + end end end end diff --git a/spec/lib/ruby_git/worktree_clone_spec.rb b/spec/lib/ruby_git/worktree_clone_spec.rb new file mode 100644 index 0000000..f984a42 --- /dev/null +++ b/spec/lib/ruby_git/worktree_clone_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'tmpdir' + +def make_bare_repository(repository_path) + Dir.mktmpdir do |tmpdir| + system('git init', chdir: tmpdir, %i[out err] => IO::NULL) + File.write(File.join(tmpdir, 'README.md'), '# THIS IS THE README') + system('git add README.md', chdir: tmpdir, %i[out err] => IO::NULL) + system('git commit -m "Initial version"', chdir: tmpdir, %i[out err] => IO::NULL) + Dir[File.join(tmpdir, '.git', '*')].each do |path| + FileUtils.mv(path, repository_path) + end + end + repository_path +end + +RSpec.describe RubyGit::Worktree do + describe '.clone(url, to_path: worktree_path)' do + subject { described_class.clone(repository_url, to_path: worktree_path) } + let(:tmpdir) { Dir.mktmpdir } + let(:repository_url) { make_bare_repository(Dir.mktmpdir) } + after do + FileUtils.rm_rf(tmpdir) if File.exist?(tmpdir) + FileUtils.rm_rf(repository_url) if File.exist?(repository_url) + end + + context 'the url is not valid' do + before { FileUtils.rm_rf(repository_url) } + let(:worktree_path) { tmpdir } + it 'should raise RubyGit::Error' do + expect { subject }.to raise_error(RubyGit::Error, /does not exist/) + end + end + + context 'the url is valid' do + let(:worktree_path) { tmpdir } + + context 'and worktree_path exists' do + context 'and is not an empty directory' do + before { FileUtils.touch(File.join(worktree_path, 'README.md')) } + it 'should raise RubyGit::Error' do + expect { subject }.to raise_error(RubyGit::Error, /not an empty directory/) + end + end + + context 'and is an empty directory' do + it 'should return a Worktree object' do + expect(subject).to be_kind_of(RubyGit::Worktree) + expect(subject).to have_attributes(path: File.realpath(worktree_path)) + end + end + end + context 'and worktree_path does not exist' do + before { FileUtils.rmdir(worktree_path) } + it 'should create the worktree path and return a Worktree object' do + expect(subject).to be_kind_of(RubyGit::Worktree) + expect(Dir.exist?(worktree_path)).to eq(true) + expect(subject).to have_attributes(path: File.realpath(worktree_path)) + end + end + end + end +end diff --git a/spec/lib/ruby_git/worktree_init_spec.rb b/spec/lib/ruby_git/worktree_init_spec.rb new file mode 100644 index 0000000..470f8ad --- /dev/null +++ b/spec/lib/ruby_git/worktree_init_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'tmpdir' + +RSpec.describe RubyGit::Worktree do + describe '.init(worktree_path)' do + subject { described_class.init(worktree_path) } + let(:tmpdir) { Dir.mktmpdir } + after { FileUtils.rm_rf(tmpdir) if File.exist?(tmpdir) } + + context 'when worktree_path does not exist' do + let(:worktree_path) { tmpdir } + before { FileUtils.rmdir(tmpdir) } + it 'should raise a RubyGit::Error' do + expect { subject }.to raise_error(RubyGit::Error) + end + end + + context 'when worktree_path exists' do + let(:worktree_path) { tmpdir } + context 'and is not a directory' do + before do + FileUtils.rmdir(worktree_path) + FileUtils.touch(worktree_path) + end + it 'should raise RubyGit::Error' do + expect { subject }.to raise_error(RubyGit::Error) + end + end + + context 'and is a directory' do + context 'and is in a worktree' do + before do + raise RuntimeError unless system('git init', chdir: worktree_path, %i[out err] => IO::NULL) + end + it 'should return a Worktree object to the existing worktree ' do + expect(subject).to be_kind_of(RubyGit::Worktree) + expect(subject).to have_attributes(path: File.realpath(worktree_path)) + end + end + + context 'and is not in a worktree' do + it 'should initialize the worktree and return a Worktree object' do + expect(subject).to be_kind_of(RubyGit::Worktree) + expect(subject).to have_attributes(path: File.realpath(worktree_path)) + end + end + end + end + end +end diff --git a/spec/lib/ruby_git/worktree_open_spec.rb b/spec/lib/ruby_git/worktree_open_spec.rb new file mode 100644 index 0000000..db69239 --- /dev/null +++ b/spec/lib/ruby_git/worktree_open_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'tmpdir' + +RSpec.describe RubyGit::Worktree do + describe '.open' do + subject { described_class.open(worktree_path) } + let(:tmpdir) { Dir.mktmpdir } + after { FileUtils.rm_rf(tmpdir) if File.exist?(tmpdir) } + + context 'when worktree_path does not exist' do + let(:worktree_path) { tmpdir } + before { FileUtils.rmdir(worktree_path) } + it 'should raise RubyGit::Error' do + expect { subject }.to raise_error(RubyGit::Error) + end + end + + context 'when worktree_path is not a directory' do + let(:worktree_path) { tmpdir } + before do + FileUtils.rmdir(worktree_path) + FileUtils.touch(worktree_path) + end + it 'should raise RubyGit::Error' do + expect { subject }.to raise_error(RubyGit::Error) + end + end + + context 'when worktree_path exists but is not a git worktree' do + let(:worktree_path) { tmpdir } + it 'should raise RubyGit::Error' do + expect { subject }.to raise_error(RubyGit::Error, /not a git repository/) + end + end + + context 'when worktree_path is a worktree path at the root of the worktree' do + let(:worktree_path) { tmpdir } + before do + raise RuntimeError unless system('git init', chdir: worktree_path, %i[out err] => IO::NULL) + end + it 'should return a Worktree object whose path is the root of the worktree' do + expect(subject).to be_kind_of(RubyGit::Worktree) + expect(subject).to have_attributes(path: File.realpath(worktree_path)) + end + end + + context 'when worktree_path is a worktree path not at the root of the worktree' do + let(:root_worktree_path) { tmpdir } + let(:worktree_path) { File.join(root_worktree_path, 'subdir') } + before do + raise RuntimeError unless system('git init', chdir: root_worktree_path, %i[out err] => IO::NULL) + + FileUtils.mkdir(worktree_path) + end + it 'should return a Worktree object whose path is the root of the worktree' do + expect(subject).to have_attributes(path: File.realpath(root_worktree_path)) + end + end + end +end diff --git a/spec/lib/ruby_git_spec.rb b/spec/lib/ruby_git_spec.rb index e4f8490..2c599c4 100644 --- a/spec/lib/ruby_git_spec.rb +++ b/spec/lib/ruby_git_spec.rb @@ -11,4 +11,37 @@ subject { described_class.git } it { is_expected.to be_kind_of(RubyGit::GitBinary) } end + + describe '.init' do + let(:worktree_path) { '/Users/jsmith/my_project' } + subject { RubyGit.init(worktree_path) } + it 'should call RubyGit::Worktree.init with the same arguments' do + worktree_class = class_double('RubyGit::Worktree') + stub_const('RubyGit::Worktree', worktree_class) + expect(worktree_class).to receive(:init).with(worktree_path) + subject + end + end + + describe '.clone' do + let(:repository_url) { 'https://github.com/main-branch/ruby_git.git' } + subject { RubyGit.clone(repository_url) } + it 'should call RubyGit::Worktree.clone with the same arguments' do + worktree_class = class_double('RubyGit::Worktree') + stub_const('RubyGit::Worktree', worktree_class) + expect(worktree_class).to receive(:clone).with(repository_url, to_path: '') + subject + end + end + + describe '.open' do + let(:worktree_path) { '/Users/jsmith/my_project' } + subject { RubyGit.open(worktree_path) } + it 'should call RubyGit::Worktree.open with the same arguments' do + worktree_class = class_double('RubyGit::Worktree') + stub_const('RubyGit::Worktree', worktree_class) + expect(worktree_class).to receive(:open).with(worktree_path) + subject + end + end end