diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index ad2ea03a..c6599412 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - ruby: [2.3, 2.7] + ruby: [2.3, 2.7, 3.0] operating-system: [ubuntu-latest] include: - ruby: head @@ -28,6 +28,9 @@ jobs: runs-on: ${{ matrix.operating-system }} + env: + JAVA_OPTS: -Djdk.io.File.enableADS=true + steps: - name: Checkout Code uses: actions/checkout@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index e7e7963b..a08297c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ # Change Log +## v1.11.0 + +* 292087e Supress unneeded test output (#570) +* 19dfe5e Add support for fetch options "--force/-f" and "--prune-tags/-P". (#563) +* 018d919 Fix bug when grepping lines that contain numbers surrounded by colons (#566) +* c04d16e remove from maintainer (#567) +* 291ca09 Address command line injection in Git::Lib#fetch +* 521b8e7 Release v1.10.2 (#561) + +See https://github.com/ruby-git/ruby-git/releases/tag/v1.11.0 + +## v1.10.2 + +See https://github.com/ruby-git/ruby-git/releases/tag/v1.10.2 + +## 1.10.1 + +See https://github.com/ruby-git/ruby-git/releases/tag/v1.10.1 + +## 1.10.0 + +See https://github.com/ruby-git/ruby-git/releases/tag/v1.10.0 + +## 1.9.1 + +See https://github.com/ruby-git/ruby-git/releases/tag/v1.9.1 + +## 1.9.0 + +See https://github.com/ruby-git/ruby-git/releases/tag/v1.9.0 + ## 1.8.1 See https://github.com/ruby-git/ruby-git/releases/tag/v1.8.1 diff --git a/Dockerfile.changelog-rs b/Dockerfile.changelog-rs new file mode 100644 index 00000000..75c35d93 --- /dev/null +++ b/Dockerfile.changelog-rs @@ -0,0 +1,12 @@ +FROM rust + +# Build the docker image (from this project's root directory): +# docker build --file Dockerfile.changelog-rs --tag changelog-rs . +# +# Use this image to output a changelog (from this project's root directory): +# docker run --rm --volume "$PWD:/worktree" changelog-rs v1.9.1 v1.10.0 + +RUN cargo install changelog-rs +WORKDIR /worktree + +ENTRYPOINT ["/usr/local/cargo/bin/changelog-rs", "/worktree"] diff --git a/Gemfile b/Gemfile index 7054c552..b2afa573 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,9 @@ +# frozen_string_literal: true + source 'https://rubygems.org' -gemspec :name => 'git' +git 'https://github.com/lsegal/yard', branch: 'main' do + gem 'yard' +end +gemspec name: 'git' diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 2d8ac7b1..ef13361f 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -10,5 +10,4 @@ When making changes in this repository, one of the maintainers below must review ### Maintainers * [Per Lundberg](https://github.com/perlun) -* [Vern Burton](https://github.com/tarcinil) * [James Couball](https://github.com/jcouball) \ No newline at end of file diff --git a/README.md b/README.md index 0ff9a0a5..d4b68c55 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ g.index.writable? g.repo g.dir -g.log # returns array of Git::Commit objects +g.log # returns a Git::Log object, which is an Enumerator of Git::Commit objects g.log.since('2 weeks ago') g.log.between('v2.5', 'v2.6') g.log.each {|l| puts l.sha } @@ -146,6 +146,7 @@ g.revparse('v2.5:Makefile') g.branches # returns Git::Branch objects g.branches.local +g.current_branch g.branches.remote g.branches[:master].gcommit g.branches['origin/master'].gcommit @@ -203,13 +204,23 @@ g = Git.init { :repository => '/opt/git/proj.git', :index => '/tmp/index'} ) -g = Git.clone(URI, NAME, :path => '/tmp/checkout') +# Clone from a git url +git_url = 'https://github.com/ruby-git/ruby-git.git' +# Clone into the ruby-git directory +g = Git.clone(git_url) + +# Clone into /tmp/clone/ruby-git-clean +name = 'ruby-git-clean' +path = '/tmp/clone' +g = Git.clone(git_url, name, :path => path) +g.dir #=> /tmp/clone/ruby-git-clean + g.config('user.name', 'Scott Chacon') g.config('user.email', 'email@email.com') # Clone can take an optional logger logger = Logger.new -g = Git.clone(URI, NAME, :log => logger) +g = Git.clone(git_url, NAME, :log => logger) g.add # git add -- "." g.add(:all=>true) # git add --all -- "." @@ -225,6 +236,14 @@ g.remove('file.txt', :cached => true) # git rm -f --cached -- "file.txt" g.commit('message') g.commit_all('message') +# Sign a commit using the gpg key configured in the user.signingkey config setting +g.config('user.signingkey', '0A46826A') +g.commit('message', gpg_sign: true) + +# Sign a commit using a specified gpg key +key_id = '0A46826A' +g.commit('message', gpg_sign: key_id) + g = Git.clone(repo, 'myrepo') g.chdir do new_file('test-file', 'blahblahblah') @@ -269,6 +288,7 @@ g.remote(name).merge(branch) g.fetch g.fetch(g.remotes.first) g.fetch('origin', {:ref => 'some/ref/head'} ) +g.fetch(all: true, force: true, depth: 2) g.pull g.pull(Git::Repo, Git::Branch) # fetch and a merge diff --git a/RELEASING.md b/RELEASING.md index 7f360370..f43697da 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -6,9 +6,11 @@ # How to release a new git.gem Releasing a new version of the `git` gem requires these steps: - * [Prepare the release](#prepare-the-release) - * [Create a GitHub release](#create-a-github-release) - * [Build and release the gem](#build-and-release-the-gem) + +- [How to release a new git.gem](#how-to-release-a-new-gitgem) + - [Prepare the release](#prepare-the-release) + - [Create a GitHub release](#create-a-github-release) + - [Build and release the gem](#build-and-release-the-gem) These instructions use an example where the current release version is `1.5.0` and the new release version to be created is `1.6.0.pre1`. @@ -18,45 +20,49 @@ and the new release version to be created is `1.6.0.pre1`. From a fork of ruby-git, create a PR containing changes to (1) bump the version number, (2) update the CHANGELOG.md, and (3) tag the release. - * Bump the version number in lib/git/version.rb following [Semantic Versioning](https://semver.org) - guidelines - * Add a link in CHANGELOG.md to the release tag which will be created later - in this guide - * Create a new tag using [git-extras](https://github.com/tj/git-extras/blob/master/Commands.md#git-release) - `git release` command - * For example: `git release v1.6.0.pre1` - * These should be the only changes in the PR - * An example of these changes for `v1.6.0.pre1` can be found in [PR #435](https://github.com/ruby-git/ruby-git/pull/435) - * Get the PR reviewed, approved and merged to master. +- Bump the version number in lib/git/version.rb following [Semantic Versioning](https://semver.org) + guidelines +- Add a link in CHANGELOG.md to the release tag which will be created later + in this guide +- Create a new tag using [git-extras](https://github.com/tj/git-extras/blob/master/Commands.md#git-release) + `git release` command + - For example: `git release v1.6.0.pre1` +- These should be the only changes in the PR +- An example of these changes for `v1.6.0.pre1` can be found in [PR #435](https://github.com/ruby-git/ruby-git/pull/435) +- Get the PR reviewed, approved and merged to master. ## Create a GitHub release On [the ruby-git releases page](https://github.com/ruby-git/ruby-git/releases), select `Draft a new release` - * Select the tag corresponding to the version being released `v1.6.0.pre1` - * The Target should be `master` - * For the release description, use the output of [changelog-rs](https://github.com/perlun/changelog-rs) - * Since the release has not been created yet, you will need to supply - `changeling-rs` with the current release tag and the tag the new release - is being created from - * For example: `changelog-rs . v1.5.0 v1.6.0.pre1` - * Copy the output, omitting the tag header `## v1.6.0.pre1` and paste into - the release description - * The release description can be edited later if needed - * Select the appropriate value for `This is a pre-release` - * Since `v1.6.0.pre1` is a pre-release, check `This is a pre-release` +- Select the tag corresponding to the version being released `v1.6.0.pre1` +- The Target should be `master` +- For the release description, use the output of [changelog-rs](https://github.com/perlun/changelog-rs) + - A Docker image is provided in [Dockerfile.changelog-rs](https://github.com/ruby-git/ruby-git/blob/master/Dockerfile.changelog-rs) + so you don't have to install changelog-rs or the Rust tool chain. To build the + Docker image, run this command from this project's root directory: + - `docker build --file Dockerfile.changelog-rs --tag changelog-rs .` + - To run the changelog-rs command using this image, run the following command + from this project's root directory (replace the tag names appropriate for the + current release): + - `docker run --rm --volume "$PWD:/worktree" changelog-rs v1.5.0 v1.6.0.pre1` + - Copy the output, omitting the tag header `## v1.6.0.pre1` and paste into + the release description + - The release description can be edited later if needed +- Select the appropriate value for `This is a pre-release` + - Since `v1.6.0.pre1` is a pre-release, check `This is a pre-release` ## Build and release the gem Clone [ruby-git/ruby-git](https://github.com/ruby-git/ruby-git) directly (not a fork) and ensure your local working copy is on the master branch - * Verify that you are not on a fork with the command `git remote -v` - * Verify that the version number is correct by running `rake -T` and inspecting - the output for the `release[remote]` task +- Verify that you are not on a fork with the command `git remote -v` +- Verify that the version number is correct by running `rake -T` and inspecting + the output for the `release[remote]` task Build the git gem and push it to rubygems.org with the command `rake release` - * Ensure that your `gem sources list` includes `https://rubygems.org` (in my - case, I usually have my work’s internal gem repository listed) +- Ensure that your `gem sources list` includes `https://rubygems.org` (in my + case, I usually have my work’s internal gem repository listed) diff --git a/bin/console b/bin/console new file mode 100755 index 00000000..0199a6fc --- /dev/null +++ b/bin/console @@ -0,0 +1,15 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'bundler/setup' +require 'git' + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require 'irb' +IRB.start(__FILE__) diff --git a/bin/create-release b/bin/create-release new file mode 100755 index 00000000..fdc8aa83 --- /dev/null +++ b/bin/create-release @@ -0,0 +1,506 @@ +#!/usr/bin/env ruby + +# Run this script while in the root directory of the project with the default +# branch checked out. + +require 'bump' +require 'English' +require 'fileutils' +require 'optparse' +require 'tempfile' + +# TODO: Right now the default branch and the remote name are hard coded + +class Options + attr_accessor :current_version, :next_version, :tag, :current_tag, :next_tag, :branch, :quiet + + def initialize + yield self if block_given? + end + + def release_type + raise "release_type not set" if @release_type.nil? + @release_type + end + + VALID_RELEASE_TYPES = %w(major minor patch) + + def release_type=(release_type) + raise 'release_type must be one of: ' + VALID_RELEASE_TYPES.join(', ') unless VALID_RELEASE_TYPES.include?(release_type) + @release_type = release_type + end + + def quiet + @quiet = false unless instance_variable_defined?(:@quiet) + @quiet + end + + def current_version + @current_version ||= Bump::Bump.current + end + + def next_version + current_version # Save the current version before bumping + @next_version ||= Bump::Bump.next_version(release_type) + end + + def tag + @tag ||= "v#{next_version}" + end + + def current_tag + @current_tag ||= "v#{current_version}" + end + + def next_tag + tag + end + + def branch + @branch ||= "release-#{tag}" + end + + def default_branch + @default_branch ||= `git remote show '#{remote}'`.match(/HEAD branch: (.*?)$/)[1] + end + + def remote + @remote ||= 'origin' + end + + def to_s + <<~OUTPUT + release_type='#{release_type}' + current_version='#{current_version}' + next_version='#{next_version}' + tag='#{tag}' + branch='#{branch}' + quiet=#{quiet} + OUTPUT + end +end + +class CommandLineParser + attr_reader :options + + def initialize + @option_parser = OptionParser.new + define_options + @options = Options.new + end + + def parse(args) + option_parser.parse!(remaining_args = args.dup) + parse_remaining_args(remaining_args) + # puts options unless options.quiet + options + end + + private + + attr_reader :option_parser + + def parse_remaining_args(remaining_args) + error_with_usage('No release type specified') if remaining_args.empty? + @options.release_type = remaining_args.shift || nil + error_with_usage('Too many args') unless remaining_args.empty? + end + + def error_with_usage(message) + warn <<~MESSAGE + ERROR: #{message} + #{option_parser} + MESSAGE + exit 1 + end + + def define_options + option_parser.banner = 'Usage: create_release --help | release-type' + option_parser.separator '' + option_parser.separator 'Options:' + + define_quiet_option + define_help_option + end + + def define_quiet_option + option_parser.on('-q', '--[no-]quiet', 'Do not show output') do |quiet| + options.quiet = quiet + end + end + + def define_help_option + option_parser.on_tail('-h', '--help', 'Show this message') do + puts option_parser + exit 0 + end + end +end + +class ReleaseAssertions + attr_reader :options + + def initialize(options) + @options = options + end + + def make_assertions + bundle_is_up_to_date + in_git_repo + in_repo_toplevel_directory + on_default_branch + no_uncommitted_changes + local_and_remote_on_same_commit + tag_does_not_exist + branch_does_not_exist + docker_is_running + changelog_docker_container_exists + gh_command_exists + end + + private + + def gh_command_exists + print "Checking that the gh command exists..." + `which gh > /dev/null 2>&1` + if $CHILD_STATUS.success? + puts "OK" + else + error "The gh command was not found" + end + end + + def docker_is_running + print "Checking that docker is installed and running..." + `docker info > /dev/null 2>&1` + if $CHILD_STATUS.success? + puts "OK" + else + error "Docker is not installed or not running" + end + end + + + def changelog_docker_container_exists + print "Checking that the changelog docker container exists (might take time to build)..." + `docker build --file Dockerfile.changelog-rs --tag changelog-rs . 1>/dev/null` + if $CHILD_STATUS.success? + puts "OK" + else + error "Failed to build the changelog-rs docker container" + end + end + + def bundle_is_up_to_date + print "Checking that the bundle is up to date..." + if File.exist?('Gemfile.lock') + print "Running bundle update..." + `bundle update --quiet` + if $CHILD_STATUS.success? + puts "OK" + else + error "bundle update failed" + end + else + print "Running bundle install..." + `bundle install --quiet` + if $CHILD_STATUS.success? + puts "OK" + else + error "bundle install failed" + end + end + end + + def in_git_repo + print "Checking that you are in a git repo..." + `git rev-parse --is-inside-work-tree --quiet > /dev/null 2>&1` + if $CHILD_STATUS.success? + puts "OK" + else + error "You are not in a git repo" + end + end + + def in_repo_toplevel_directory + print "Checking that you are in the repo's toplevel directory..." + toplevel_directory = `git rev-parse --show-toplevel`.chomp + if toplevel_directory == FileUtils.pwd + puts "OK" + else + error "You are not in the repo's toplevel directory" + end + end + + def on_default_branch + print "Checking that you are on the default branch..." + current_branch = `git branch --show-current`.chomp + if current_branch == options.default_branch + puts "OK" + else + error "You are not on the default branch '#{default_branch}'" + end + end + + def no_uncommitted_changes + print "Checking that there are no uncommitted changes..." + if `git status --porcelain | wc -l`.to_i == 0 + puts "OK" + else + error "There are uncommitted changes" + end + end + + def no_staged_changes + print "Checking that there are no staged changes..." + if `git diff --staged --name-only | wc -l`.to_i == 0 + puts "OK" + else + error "There are staged changes" + end + end + + def local_and_remote_on_same_commit + print "Checking that local and remote are on the same commit..." + local_commit = `git rev-parse HEAD`.chomp + remote_commit = `git ls-remote '#{options.remote}' '#{options.default_branch}' | cut -f 1`.chomp + if local_commit == remote_commit + puts "OK" + else + error "Local and remote are not on the same commit" + end + end + + def local_tag_does_not_exist + print "Checking that local tag '#{options.tag}' does not exist..." + + tags = `git tag --list "#{options.tag}"`.chomp + error 'Could not list tags' unless $CHILD_STATUS.success? + + if tags.split.empty? + puts 'OK' + else + error "'#{options.tag}' already exists" + end + end + + def remote_tag_does_not_exist + print "Checking that the remote tag '#{options.tag}' does not exist..." + `git ls-remote --tags --exit-code '#{options.remote}' #{options.tag} >/dev/null 2>&1` + unless $CHILD_STATUS.success? + puts "OK" + else + error "'#{options.tag}' already exists" + end + end + + def tag_does_not_exist + local_tag_does_not_exist + remote_tag_does_not_exist + end + + def local_branch_does_not_exist + print "Checking that local branch '#{options.branch}' does not exist..." + + if `git branch --list "#{options.branch}" | wc -l`.to_i.zero? + puts "OK" + else + error "'#{options.branch}' already exists." + end + end + + def remote_branch_does_not_exist + print "Checking that the remote branch '#{options.branch}' does not exist..." + `git ls-remote --heads --exit-code '#{options.remote}' '#{options.branch}' >/dev/null 2>&1` + unless $CHILD_STATUS.success? + puts "OK" + else + error "'#{options.branch}' already exists" + end + end + + def branch_does_not_exist + local_branch_does_not_exist + remote_branch_does_not_exist + end + + private + + def print(*args) + super unless options.quiet + end + + def puts(*args) + super unless options.quiet + end + + def error(message) + warn "ERROR: #{message}" + exit 1 + end +end + +class ReleaseCreator + attr_reader :options + + def initialize(options) + @options = options + end + + def create_release + create_branch + update_changelog + update_version + make_release_commit + create_tag + push_release_commit_and_tag + create_github_release + create_release_pull_request + end + + private + + def create_branch + print "Creating branch '#{options.branch}'..." + `git checkout -b "#{options.branch}" > /dev/null 2>&1` + if $CHILD_STATUS.success? + puts "OK" + else + error "Could not create branch '#{options.branch}'" unless $CHILD_STATUS.success? + end + end + + def update_changelog + print 'Updating CHANGELOG.md...' + changelog_lines = File.readlines('CHANGELOG.md') + first_entry = changelog_lines.index { |e| e =~ /^## / } + error "Could not find changelog insertion point" unless first_entry + FileUtils.rm('CHANGELOG.md') + File.write('CHANGELOG.md', <<~CHANGELOG.chomp) + #{changelog_lines[0..first_entry - 1].join}## #{options.tag} + + See https://github.com/ruby-git/ruby-git/releases/tag/#{options.tag} + + #{changelog_lines[first_entry..].join} + CHANGELOG + `git add CHANGELOG.md` + if $CHILD_STATUS.success? + puts 'OK' + else + error 'Could not stage changes to CHANGELOG.md' + end + end + + def update_version + print 'Updating version...' + message, status = Bump::Bump.run(options.release_type, commit: false) + error 'Could not bump version' unless status == 0 + `git add lib/git/version.rb` + if $CHILD_STATUS.success? + puts 'OK' + else + error 'Could not stage changes to lib/git/version.rb' + end + end + + def make_release_commit + print 'Making release commit...' + `git commit -s -m 'Release #{options.tag}'` + error 'Could not make release commit' unless $CHILD_STATUS.success? + end + + def create_tag + print "Creating tag '#{options.tag}'..." + `git tag '#{options.tag}'` + if $CHILD_STATUS.success? + puts 'OK' + else + error "Could not create tag '#{options.tag}'" + end + end + + def push_release_commit_and_tag + print "Pushing branch '#{options.branch}' to remote..." + `git push --tags --set-upstream '#{options.remote}' '#{options.branch}' > /dev/null 2>&1` + if $CHILD_STATUS.success? + puts 'OK' + else + error 'Could not push release commit' + end + end + + def changelog + @changelog ||= begin + print "Generating changelog..." + pwd = FileUtils.pwd + from = options.current_tag + to = options.next_tag + command = "docker run --rm --volume '#{pwd}:/worktree' changelog-rs '#{from}' '#{to}'" + changelog = `#{command}` + if $CHILD_STATUS.success? + puts 'OK' + changelog.rstrip.lines[1..].join + else + error 'Could not generate the changelog' + end + end + end + + def create_github_release + Tempfile.create do |f| + f.write changelog + f.close + + print "Creating GitHub release '#{options.tag}'..." + tag = options.tag + `gh release create #{tag} --title 'Release #{tag}' --notes-file '#{f.path}' --target #{options.default_branch}` + if $CHILD_STATUS.success? + puts 'OK' + else + error 'Could not create release' + end + end + end + + def create_release_pull_request + Tempfile.create do |f| + f.write <<~PR + ### Your checklist for this pull request + 🚨Please review the [guidelines for contributing](https://github.com/ruby-git/ruby-git/blob/#{options.default_branch}/CONTRIBUTING.md) to this repository. + + - [X] Ensure all commits include DCO sign-off. + - [X] Ensure that your contributions pass unit testing. + - [X] Ensure that your contributions contain documentation if applicable. + + ### Description + #{changelog} + PR + f.close + + print "Creating GitHub pull request..." + `gh pr create --title 'Release #{options.tag}' --body-file '#{f.path}' --base '#{options.default_branch}'` + if $CHILD_STATUS.success? + puts 'OK' + else + error 'Could not create release pull request' + end + end + end + + def error(message) + warn "ERROR: #{message}" + exit 1 + end + + def print(*args) + super unless options.quiet + end + + def puts(*args) + super unless options.quiet + end +end + +options = CommandLineParser.new.parse(ARGV) +ReleaseAssertions.new(options).make_assertions +ReleaseCreator.new(options).create_release diff --git a/bin/setup b/bin/setup new file mode 100755 index 00000000..dce67d86 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/git.gemspec b/git.gemspec index 53994aa2..ac2ff000 100644 --- a/git.gemspec +++ b/git.gemspec @@ -26,8 +26,10 @@ Gem::Specification.new do |s| s.required_rubygems_version = Gem::Requirement.new('>= 0') if s.respond_to?(:required_rubygems_version=) s.requirements = ['git 1.6.0.0, or greater'] + s.add_runtime_dependency 'addressable', '~> 2.8' s.add_runtime_dependency 'rchardet', '~> 1.8' + s.add_development_dependency 'bump', '~> 0.10' s.add_development_dependency 'minitar', '~> 0.9' s.add_development_dependency 'rake', '~> 13.0' s.add_development_dependency 'test-unit', '~> 3.3' @@ -41,6 +43,6 @@ Gem::Specification.new do |s| # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. s.files = Dir.chdir(File.expand_path(__dir__)) do - `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(tests|spec|features)/}) } + `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(tests|spec|features|bin)/}) } end end diff --git a/lib/git.rb b/lib/git.rb index eb4c7cce..1da03ce5 100644 --- a/lib/git.rb +++ b/lib/git.rb @@ -9,6 +9,8 @@ require 'git/branches' require 'git/config' require 'git/diff' +require 'git/encoding_utils' +require 'git/escaped_path' require 'git/index' require 'git/lib' require 'git/log' @@ -19,6 +21,7 @@ require 'git/status' require 'git/stash' require 'git/stashes' +require 'git/url' require 'git/version' require 'git/working_directory' require 'git/worktree' @@ -104,11 +107,23 @@ def self.bare(git_dir, options = {}) # @see https://git-scm.com/docs/git-clone git clone # @see https://git-scm.com/docs/git-clone#_git_urls_a_id_urls_a GIT URLs # - # @param [URI, Pathname] repository The (possibly remote) repository to clone + # @param repository_url [URI, Pathname] The (possibly remote) repository url to clone # from. See [GIT URLS](https://git-scm.com/docs/git-clone#_git_urls_a_id_urls_a) # for more information. # - # @param [Pathname] name The directory to clone into. + # @param directory [Pathname, nil] The directory to clone into + # + # If `directory` is a relative directory it is relative to the `path` option if + # given. If `path` is not given, `directory` is relative to the current working + # directory. + # + # If `nil`, `directory` will be set to the basename of the last component of + # the path from the `repository_url`. For example, for the URL: + # `https://github.com/org/repo.git`, `directory` will be set to `repo`. + # + # If the last component of the path is `.git`, the next-to-last component of + # the path is used. For example, for the URL `/Users/me/foo/.git`, `directory` + # will be set to `foo`. # # @param [Hash] options The options for this command (see list of valid # options below) @@ -155,8 +170,10 @@ def self.bare(git_dir, options = {}) # @return [Git::Base] an object that can execute git commands in the context # of the cloned local working copy or cloned repository. # - def self.clone(repository, name, options = {}) - Base.clone(repository, name, options) + def self.clone(repository_url, directory = nil, options = {}) + clone_to_options = options.select { |key, _value| %i[bare mirror].include?(key) } + directory ||= Git::URL.clone_to(repository_url, **clone_to_options) + Base.clone(repository_url, directory, options) end # Export the current HEAD (or a branch, if options[:branch] @@ -212,6 +229,9 @@ def self.global_config(name = nil, value = nil) # `"#{directory}/.git"`, create a bare repository at `"#{directory}"`. # See [what is a bare repository?](https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefbarerepositoryabarerepository). # + # @option options [String] :initial_branch Use the specified name for the + # initial branch in the newly created repository. + # # @option options [Pathname] :repository the path to put the newly initialized # Git repository. The default for non-bare repository is `"#{directory}/.git"`. # diff --git a/lib/git/base.rb b/lib/git/base.rb index 94e59c21..c58bafe8 100644 --- a/lib/git/base.rb +++ b/lib/git/base.rb @@ -12,40 +12,35 @@ class Base # (see Git.bare) def self.bare(git_dir, options = {}) - self.new({:repository => git_dir}.merge(options)) + normalize_paths(options, default_repository: git_dir, bare: true) + self.new(options) end # (see Git.clone) - def self.clone(repository, name, options = {}) - self.new(Git::Lib.new(nil, options[:log]).clone(repository, name, options)) + def self.clone(repository_url, directory, options = {}) + new_options = Git::Lib.new(nil, options[:log]).clone(repository_url, directory, options) + normalize_paths(new_options, bare: options[:bare] || options[:mirror]) + new(new_options) end # Returns (and initialize if needed) a Git::Config instance # # @return [Git::Config] the current config instance. def self.config - return @@config ||= Config.new + @@config ||= Config.new end # (see Git.init) - def self.init(directory, options = {}) - options[:working_directory] ||= directory - options[:repository] ||= File.join(options[:working_directory], '.git') - - FileUtils.mkdir_p(options[:working_directory]) if options[:working_directory] && !File.directory?(options[:working_directory]) + def self.init(directory = '.', options = {}) + normalize_paths(options, default_working_directory: directory, default_repository: directory, bare: options[:bare]) - init_options = { :bare => options[:bare] } + init_options = { + :bare => options[:bare], + :initial_branch => options[:initial_branch] + } - options.delete(:working_directory) if options[:bare] - - # Submodules have a .git *file* not a .git folder. - # This file's contents point to the location of - # where the git refs are held (In the parent repo) - if options[:working_directory] && File.file?(File.join(options[:working_directory], '.git')) - git_file = File.open('.git').read[8..-1].strip - options[:repository] = git_file - options[:index] = git_file + '/index' - end + directory = options[:bare] ? options[:repository] : options[:working_directory] + FileUtils.mkdir_p(directory) unless File.exist?(directory) # TODO: this dance seems awkward: this creates a Git::Lib so we can call # init so we can create a new Git::Base which in turn (ultimately) @@ -63,21 +58,8 @@ def self.init(directory, options = {}) end # (see Git.open) - def self.open(working_dir, options={}) - # TODO: move this to Git.open? - - options[:working_directory] ||= working_dir - options[:repository] ||= File.join(options[:working_directory], '.git') - - # Submodules have a .git *file* not a .git folder. - # This file's contents point to the location of - # where the git refs are held (In the parent repo) - if options[:working_directory] && File.file?(File.join(options[:working_directory], '.git')) - git_file = File.open('.git').read[8..-1].strip - options[:repository] = git_file - options[:index] = git_file + '/index' - end - + def self.open(working_dir, options = {}) + normalize_paths(options, default_working_directory: working_dir) self.new(options) end @@ -137,13 +119,14 @@ def chdir # :yields: the Git::Path #g.config('user.name', 'Scott Chacon') # sets value #g.config('user.email', 'email@email.com') # sets value + #g.config('user.email', 'email@email.com', file: 'path/to/custom/config) # sets value in file #g.config('user.name') # returns 'Scott Chacon' #g.config # returns whole config hash - def config(name = nil, value = nil) - if(name && value) + def config(name = nil, value = nil, options = {}) + if name && value # set value - lib.config_set(name, value) - elsif (name) + lib.config_set(name, value, options) + elsif name # return value lib.config_get(name) else @@ -286,6 +269,7 @@ def reset_hard(commitish = nil, opts = {}) # options: # :force # :d + # :ff # def clean(opts = {}) self.lib.clean(opts) @@ -352,7 +336,11 @@ def checkout_file(version, file) # fetches changes from a remote branch - this does not modify the working directory, # it just gets the changes from the remote if there are any - def fetch(remote = 'origin', opts={}) + def fetch(remote = 'origin', opts = {}) + if remote.is_a?(Hash) + opts = remote + remote = nil + end self.lib.fetch(remote, opts) end @@ -569,7 +557,6 @@ def with_temp_working &blk with_working(temp_dir, &blk) end - # runs git rev-parse to convert the objectish to a full sha # # @example @@ -594,6 +581,93 @@ def current_branch self.lib.branch_current end - end + private + + # Normalize options before they are sent to Git::Base.new + # + # Updates the options parameter by setting appropriate values for the following keys: + # * options[:working_directory] + # * options[:repository] + # * options[:index] + # + # All three values will be set to absolute paths. An exception is that + # :working_directory will be set to nil if bare is true. + # + private_class_method def self.normalize_paths( + options, default_working_directory: nil, default_repository: nil, bare: false + ) + normalize_working_directory(options, default: default_working_directory, bare: bare) + normalize_repository(options, default: default_repository, bare: bare) + normalize_index(options) + end + + # Normalize options[:working_directory] + # + # If working with a bare repository, set to `nil`. + # Otherwise, set to the first non-nil value of: + # 1. `options[:working_directory]`, + # 2. the `default` parameter, or + # 3. the current working directory + # + # Finally, if options[:working_directory] is a relative path, convert it to an absoluite + # path relative to the current directory. + # + private_class_method def self.normalize_working_directory(options, default:, bare: false) + working_directory = + if bare + nil + else + File.expand_path(options[:working_directory] || default || Dir.pwd) + end + + options[:working_directory] = working_directory + end + + # Normalize options[:repository] + # + # If working with a bare repository, set to the first non-nil value out of: + # 1. `options[:repository]` + # 2. the `default` parameter + # 3. the current working directory + # + # Otherwise, set to the first non-nil value of: + # 1. `options[:repository]` + # 2. `.git` + # + # Next, if options[:repository] refers to a *file* and not a *directory*, set + # options[:repository] to the contents of that file. This is the case when + # working with a submodule or a secondary working tree (created with git worktree + # add). In these cases the repository is actually contained/nested within the + # parent's repository directory. + # + # Finally, if options[:repository] is a relative path, convert it to an absolute + # path relative to: + # 1. the current directory if working with a bare repository or + # 2. the working directory if NOT working with a bare repository + # + private_class_method def self.normalize_repository(options, default:, bare: false) + repository = + if bare + File.expand_path(options[:repository] || default || Dir.pwd) + else + File.expand_path(options[:repository] || '.git', options[:working_directory]) + end + if File.file?(repository) + repository = File.expand_path(File.open(repository).read[8..-1].strip, options[:working_directory]) + end + + options[:repository] = repository + end + + # Normalize options[:index] + # + # If options[:index] is a relative directory, convert it to an absolute + # directory relative to the repository directory + # + private_class_method def self.normalize_index(options) + index = File.expand_path(options[:index] || 'index', options[:repository]) + options[:index] = index + end + end end diff --git a/lib/git/diff.rb b/lib/git/diff.rb index e34ba82c..78f02745 100644 --- a/lib/git/diff.rb +++ b/lib/git/diff.rb @@ -130,8 +130,8 @@ def process_full_diff final = {} current_file = nil @full_diff.split("\n").each do |line| - if m = /^diff --git a\/(.*?) b\/(.*?)/.match(line) - current_file = m[1] + if m = %r{\Adiff --git ("?)a/(.+?)\1 ("?)b/(.+?)\3\z}.match(line) + current_file = Git::EscapedPath.new(m[2]).unescape final[current_file] = defaults.merge({:patch => line, :path => current_file}) else if m = /^index ([0-9a-f]{4,40})\.\.([0-9a-f]{4,40})( ......)*/.match(line) diff --git a/lib/git/encoding_utils.rb b/lib/git/encoding_utils.rb new file mode 100644 index 00000000..332b5461 --- /dev/null +++ b/lib/git/encoding_utils.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'rchardet' + +module Git + # Method that can be used to detect and normalize string encoding + module EncodingUtils + def self.default_encoding + __ENCODING__.name + end + + def self.best_guess_encoding + # Encoding::ASCII_8BIT.name + Encoding::UTF_8.name + end + + def self.detected_encoding(str) + CharDet.detect(str)['encoding'] || best_guess_encoding + end + + def self.encoding_options + { invalid: :replace, undef: :replace } + end + + def self.normalize_encoding(str) + return str if str.valid_encoding? && str.encoding.name == default_encoding + + return str.encode(default_encoding, str.encoding, **encoding_options) if str.valid_encoding? + + str.encode(default_encoding, detected_encoding(str), **encoding_options) + end + end +end diff --git a/lib/git/escaped_path.rb b/lib/git/escaped_path.rb new file mode 100644 index 00000000..7519a3ac --- /dev/null +++ b/lib/git/escaped_path.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Git + # Represents an escaped Git path string + # + # Git commands that output paths (e.g. ls-files, diff), will escape usual + # characters in the path with backslashes in the same way C escapes control + # characters (e.g. \t for TAB, \n for LF, \\ for backslash) or bytes with values + # larger than 0x80 (e.g. octal \302\265 for "micro" in UTF-8). + # + # @example + # Git::GitPath.new('\302\265').unescape # => "µ" + # + class EscapedPath + UNESCAPES = { + 'a' => 0x07, + 'b' => 0x08, + 't' => 0x09, + 'n' => 0x0a, + 'v' => 0x0b, + 'f' => 0x0c, + 'r' => 0x0d, + 'e' => 0x1b, + '\\' => 0x5c, + '"' => 0x22, + "'" => 0x27 + }.freeze + + attr_reader :path + + def initialize(path) + @path = path + end + + # Convert an escaped path to an unescaped path + def unescape + bytes = escaped_path_to_bytes(path) + str = bytes.pack('C*') + str.force_encoding(Encoding::UTF_8) + end + + private + + def extract_octal(path, index) + [path[index + 1..index + 4].to_i(8), 4] + end + + def extract_escape(path, index) + [UNESCAPES[path[index + 1]], 2] + end + + def extract_single_char(path, index) + [path[index].ord, 1] + end + + def next_byte(path, index) + if path[index] == '\\' && path[index + 1] >= '0' && path[index + 1] <= '7' + extract_octal(path, index) + elsif path[index] == '\\' && UNESCAPES.include?(path[index + 1]) + extract_escape(path, index) + else + extract_single_char(path, index) + end + end + + def escaped_path_to_bytes(path) + index = 0 + [].tap do |bytes| + while index < path.length + byte, chars_used = next_byte(path, index) + bytes << byte + index += chars_used + end + end + end + end +end diff --git a/lib/git/lib.rb b/lib/git/lib.rb index d69205f4..f24bf2da 100644 --- a/lib/git/lib.rb +++ b/lib/git/lib.rb @@ -1,4 +1,3 @@ -require 'rchardet' require 'tempfile' require 'zlib' @@ -71,10 +70,12 @@ def initialize(base = nil, logger = nil) # options: # :bare # :working_directory + # :initial_branch # def init(opts={}) arr_opts = [] arr_opts << '--bare' if opts[:bare] + arr_opts << "--initial-branch=#{opts[:initial_branch]}" if opts[:initial_branch] command('init', arr_opts) end @@ -94,9 +95,9 @@ def init(opts={}) # # @return [Hash] the options to pass to {Git::Base.new} # - def clone(repository, name, opts = {}) + def clone(repository_url, directory, opts = {}) @path = opts[:path] || '.' - clone_dir = opts[:path] ? File.join(@path, name) : name + clone_dir = opts[:path] ? File.join(@path, directory) : directory arr_opts = [] arr_opts << '--bare' if opts[:bare] @@ -105,11 +106,11 @@ def clone(repository, name, opts = {}) arr_opts << '--config' << opts[:config] if opts[:config] arr_opts << '--origin' << opts[:remote] || opts[:origin] if opts[:remote] || opts[:origin] arr_opts << '--recursive' if opts[:recursive] - arr_opts << "--mirror" if opts[:mirror] + arr_opts << '--mirror' if opts[:mirror] arr_opts << '--' - arr_opts << repository + arr_opts << repository_url arr_opts << clone_dir command('clone', arr_opts) @@ -418,7 +419,7 @@ def grep(string, opts = {}) hsh = {} command_lines('grep', grep_opts).each do |line| - if m = /(.*)\:(\d+)\:(.*)/.match(line) + if m = /(.*?)\:(\d+)\:(.*)/.match(line) hsh[m[1]] ||= [] hsh[m[1]] << [m[2].to_i, m[3]] end @@ -586,8 +587,12 @@ def show(objectish=nil, path=nil) ## WRITE COMMANDS ## - def config_set(name, value) - command('config', name, value) + def config_set(name, value, options = {}) + if options[:file].to_s.empty? + command('config', name, value) + else + command('config', '--file', options[:file], name, value) + end end def global_config_set(name, value) @@ -645,6 +650,7 @@ def remove(path = '.', opts = {}) # :date # :no_verify # :allow_empty_message + # :gpg_sign # # @param [String] message the commit message to be used # @param [Hash] opts the commit options to be used @@ -658,6 +664,14 @@ def commit(message, opts = {}) arr_opts << "--date=#{opts[:date]}" if opts[:date].is_a? String arr_opts << '--no-verify' if opts[:no_verify] arr_opts << '--allow-empty-message' if opts[:allow_empty_message] + if opts[:gpg_sign] + arr_opts << + if opts[:gpg_sign] == true + '--gpg-sign' + else + "--gpg-sign=#{opts[:gpg_sign]}" + end + end command('commit', arr_opts) end @@ -672,6 +686,7 @@ def reset(commit, opts = {}) def clean(opts = {}) arr_opts = [] arr_opts << '--force' if opts[:force] + arr_opts << '-ff' if opts[:ff] arr_opts << '-d' if opts[:d] arr_opts << '-x' if opts[:x] @@ -762,6 +777,7 @@ def checkout_file(version, file) def merge(branch, message = nil, opts = {}) arr_opts = [] + arr_opts << '--no-commit' if opts[:no_commit] arr_opts << '--no-ff' if opts[:no_ff] arr_opts << '-m' << message if message arr_opts += [branch] @@ -865,13 +881,18 @@ def tag(name, *opts) command('tag', arr_opts) end - def fetch(remote, opts) - arr_opts = [remote] - arr_opts << opts[:ref] if opts[:ref] + arr_opts = [] + arr_opts << '--all' if opts[:all] arr_opts << '--tags' if opts[:t] || opts[:tags] arr_opts << '--prune' if opts[:p] || opts[:prune] + arr_opts << '--prune-tags' if opts[:P] || opts[:'prune-tags'] + arr_opts << '--force' if opts[:f] || opts[:force] arr_opts << '--unshallow' if opts[:unshallow] + arr_opts << '--depth' << opts[:depth] if opts[:depth] + arr_opts << '--' if remote || opts[:ref] + arr_opts << remote if remote + arr_opts << opts[:ref] if opts[:ref] command('fetch', arr_opts) end @@ -1077,7 +1098,8 @@ def command(cmd, *opts, &block) end global_opts << "--git-dir=#{@git_dir}" if !@git_dir.nil? global_opts << "--work-tree=#{@git_work_dir}" if !@git_work_dir.nil? - global_opts << ["-c", "color.ui=false"] + global_opts << %w[-c core.quotePath=true] + global_opts << %w[-c color.ui=false] opts = [opts].flatten.map {|s| escape(s) }.join(' ') @@ -1168,35 +1190,10 @@ def log_path_options(opts) arr_opts end - def default_encoding - __ENCODING__.name - end - - def best_guess_encoding - # Encoding::ASCII_8BIT.name - Encoding::UTF_8.name - end - - def detected_encoding(str) - CharDet.detect(str)['encoding'] || best_guess_encoding - end - - def encoding_options - { invalid: :replace, undef: :replace } - end - - def normalize_encoding(str) - return str if str.valid_encoding? && str.encoding.name == default_encoding - - return str.encode(default_encoding, str.encoding, **encoding_options) if str.valid_encoding? - - str.encode(default_encoding, detected_encoding(str), **encoding_options) - end - def run_command(git_cmd, &block) return IO.popen(git_cmd, &block) if block_given? - `#{git_cmd}`.lines.map { |l| normalize_encoding(l) }.join + `#{git_cmd}`.lines.map { |l| Git::EncodingUtils.normalize_encoding(l) }.join end def escape(s) @@ -1208,8 +1205,9 @@ def escape_for_sh(s) end def escape_for_windows(s) - # Windows does not need single quote escaping inside double quotes - %Q{"#{s}"} + # Escape existing double quotes in s and then wrap the result with double quotes + escaped_string = s.to_s.gsub('"','\\"') + %Q{"#{escaped_string}"} end def windows_platform? @@ -1217,6 +1215,5 @@ def windows_platform? win_platform_regex = /mingw|mswin/ RUBY_PLATFORM =~ win_platform_regex || RUBY_DESCRIPTION =~ win_platform_regex end - end end diff --git a/lib/git/url.rb b/lib/git/url.rb new file mode 100644 index 00000000..af170615 --- /dev/null +++ b/lib/git/url.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require 'addressable/uri' + +module Git + # Methods for parsing a Git URL + # + # Any URL that can be passed to `git clone` can be parsed by this class. + # + # @see https://git-scm.com/docs/git-clone#_git_urls GIT URLs + # @see https://github.com/sporkmonger/addressable Addresable::URI + # + # @api public + # + class URL + # Regexp used to match a Git URL with an alternative SSH syntax + # such as `user@host:path` + # + GIT_ALTERNATIVE_SSH_SYNTAX = %r{ + ^ + (?:(?[^@/]+)@)? # user or nil + (?[^:/]+) # host is required + :(?!/) # : serparator is required, but must not be followed by / + (?.*?) # path is required + $ + }x.freeze + + # Parse a Git URL and return an Addressable::URI object + # + # The URI returned can be converted back to a string with 'to_s'. This is + # guaranteed to return the same URL string that was parsed. + # + # @example + # uri = Git::URL.parse('https://github.com/ruby-git/ruby-git.git') + # #=> # + # uri.scheme #=> "https" + # uri.host #=> "github.com" + # uri.path #=> "/ruby-git/ruby-git.git" + # + # Git::URL.parse('/Users/James/projects/ruby-git') + # #=> # + # + # @param url [String] the Git URL to parse + # + # @return [Addressable::URI] the parsed URI + # + def self.parse(url) + if !url.start_with?('file:') && (m = GIT_ALTERNATIVE_SSH_SYNTAX.match(url)) + GitAltURI.new(user: m[:user], host: m[:host], path: m[:path]) + else + Addressable::URI.parse(url) + end + end + + # The directory `git clone` would use for the repository directory for the given URL + # + # @example + # Git::URL.clone_to('https://github.com/ruby-git/ruby-git.git') #=> 'ruby-git' + # + # @param url [String] the Git URL containing the repository directory + # + # @return [String] the name of the repository directory + # + def self.clone_to(url, bare: false, mirror: false) + uri = parse(url) + path_parts = uri.path.split('/') + path_parts.pop if path_parts.last == '.git' + directory = path_parts.last + if bare || mirror + directory += '.git' unless directory.end_with?('.git') + elsif directory.end_with?('.git') + directory = directory[0..-5] + end + directory + end + end + + # The URI for git's alternative scp-like syntax + # + # This class is necessary to ensure that #to_s returns the same string + # that was passed to the initializer. + # + # @api public + # + class GitAltURI < Addressable::URI + # Create a new GitAltURI object + # + # @example + # uri = Git::GitAltURI.new(user: 'james', host: 'github.com', path: 'james/ruby-git') + # uri.to_s #=> 'james@github.com/james/ruby-git' + # + # @param user [String, nil] the user from the URL or nil + # @param host [String] the host from the URL + # @param path [String] the path from the URL + # + def initialize(user:, host:, path:) + super(scheme: 'git-alt', user: user, host: host, path: path) + end + + # Convert the URI to a String + # + # Addressible::URI forces path to be absolute by prepending a '/' to the + # path. This method removes the '/' when converting back to a string + # since that is what is expected by git. The following is a valid git URL: + # + # `james@github.com:ruby-git/ruby-git.git` + # + # and the following (with the initial '/'' in the path) is NOT a valid git URL: + # + # `james@github.com:/ruby-git/ruby-git.git` + # + # @example + # uri = Git::GitAltURI.new(user: 'james', host: 'github.com', path: 'james/ruby-git') + # uri.path #=> '/james/ruby-git' + # uri.to_s #=> 'james@github.com:james/ruby-git' + # + # @return [String] the URI as a String + # + def to_s + if user + "#{user}@#{host}:#{path[1..-1]}" + else + "#{host}:#{path[1..-1]}" + end + end + end +end diff --git a/lib/git/version.rb b/lib/git/version.rb index 05b60fb1..87bffb51 100644 --- a/lib/git/version.rb +++ b/lib/git/version.rb @@ -1,5 +1,5 @@ module Git # The current gem version # @return [String] the current gem version. - VERSION='1.8.1' + VERSION='1.11.0' end diff --git a/tests/files/working/colon_numbers.txt b/tests/files/working/colon_numbers.txt new file mode 100644 index 00000000..e76778b7 --- /dev/null +++ b/tests/files/working/colon_numbers.txt @@ -0,0 +1 @@ +Grep regex doesn't like this:4342: because it is bad diff --git a/tests/files/working/dot_git/index b/tests/files/working/dot_git/index index ef22be73..9896710a 100644 Binary files a/tests/files/working/dot_git/index and b/tests/files/working/dot_git/index differ diff --git a/tests/files/working/dot_git/logs/HEAD b/tests/files/working/dot_git/logs/HEAD index a48f0312..cbe9b80e 100644 --- a/tests/files/working/dot_git/logs/HEAD +++ b/tests/files/working/dot_git/logs/HEAD @@ -79,3 +79,4 @@ a3db7143944dcfa006fefe7fb49c48793cb29ade 34a566d193dc4702f03149969a2aad144323156 6f09de178a27f7702c37907fd614c3c122d33c30 faf8d899a0f123c3c5def10857920be1c930e8ed Scott Chacon 1378910110 -0400 commit (merge): Merge commit '4ce44a75510cbfe200b131fdbcc56a86f1b2dc08' into cherry faf8d899a0f123c3c5def10857920be1c930e8ed 5e392652a881999392c2757cf9b783c5d47b67f7 Scott Chacon 1378910135 -0400 checkout: moving from cherry to master 5e392652a881999392c2757cf9b783c5d47b67f7 5e53019b3238362144c2766f02a2c00d91fcc023 Scott Chacon 1378910138 -0400 checkout: moving from master to git_grep +5e53019b3238362144c2766f02a2c00d91fcc023 46abbf07e3c564c723c7c039a43ab3a39e5d02dd Scott Chacon 1647231179 +1300 commit: add example for grep with colon and numbers diff --git a/tests/files/working/dot_git/logs/refs/heads/git_grep b/tests/files/working/dot_git/logs/refs/heads/git_grep index 0123a146..22a6f143 100644 --- a/tests/files/working/dot_git/logs/refs/heads/git_grep +++ b/tests/files/working/dot_git/logs/refs/heads/git_grep @@ -3,3 +3,4 @@ a3db7143944dcfa006fefe7fb49c48793cb29ade 34a566d193dc4702f03149969a2aad1443231560 scott Chacon 1194632975 -0800 commit: modified to not show up 34a566d193dc4702f03149969a2aad1443231560 935badc874edd62a8629aaf103418092c73f0a56 scott Chacon 1194633382 -0800 commit: more search help 935badc874edd62a8629aaf103418092c73f0a56 5e53019b3238362144c2766f02a2c00d91fcc023 scott Chacon 1194720731 -0800 commit: diff test +5e53019b3238362144c2766f02a2c00d91fcc023 46abbf07e3c564c723c7c039a43ab3a39e5d02dd Scott Chacon 1647231179 +1300 commit: add example for grep with colon and numbers diff --git a/tests/files/working/dot_git/objects/46/abbf07e3c564c723c7c039a43ab3a39e5d02dd b/tests/files/working/dot_git/objects/46/abbf07e3c564c723c7c039a43ab3a39e5d02dd new file mode 100644 index 00000000..9675e231 --- /dev/null +++ b/tests/files/working/dot_git/objects/46/abbf07e3c564c723c7c039a43ab3a39e5d02dd @@ -0,0 +1 @@ +xQj0DSH+A('XVG0<I-ezS"YƜ2ėe#K9сuq/>&9lQMe𳯵ᶲ+_!| ӌ޹9»֚Aal 7=Àdz,/RL \ No newline at end of file diff --git a/tests/files/working/dot_git/objects/55/cbfe9fdf29da8b9dac05cb3c515055fe52ac2d b/tests/files/working/dot_git/objects/55/cbfe9fdf29da8b9dac05cb3c515055fe52ac2d new file mode 100644 index 00000000..8ea983cf Binary files /dev/null and b/tests/files/working/dot_git/objects/55/cbfe9fdf29da8b9dac05cb3c515055fe52ac2d differ diff --git a/tests/files/working/dot_git/objects/e7/6778b73006b0dda0dd56e9257c5bf6b6dd3373 b/tests/files/working/dot_git/objects/e7/6778b73006b0dda0dd56e9257c5bf6b6dd3373 new file mode 100644 index 00000000..28df1dc0 Binary files /dev/null and b/tests/files/working/dot_git/objects/e7/6778b73006b0dda0dd56e9257c5bf6b6dd3373 differ diff --git a/tests/files/working/dot_git/refs/heads/git_grep b/tests/files/working/dot_git/refs/heads/git_grep index 475c8590..0392fbf4 100644 --- a/tests/files/working/dot_git/refs/heads/git_grep +++ b/tests/files/working/dot_git/refs/heads/git_grep @@ -1 +1 @@ -5e53019b3238362144c2766f02a2c00d91fcc023 +46abbf07e3c564c723c7c039a43ab3a39e5d02dd diff --git a/tests/files/working/dot_git/refs/tags/grep_colon_numbers b/tests/files/working/dot_git/refs/tags/grep_colon_numbers new file mode 100644 index 00000000..0392fbf4 --- /dev/null +++ b/tests/files/working/dot_git/refs/tags/grep_colon_numbers @@ -0,0 +1 @@ +46abbf07e3c564c723c7c039a43ab3a39e5d02dd diff --git a/tests/test_helper.rb b/tests/test_helper.rb index b04f3f4d..31ed8477 100644 --- a/tests/test_helper.rb +++ b/tests/test_helper.rb @@ -97,4 +97,67 @@ def with_custom_env_variables(&block) Git::Lib::ENV_VARIABLE_NAMES.each { |k| ENV[k] = saved_env[k] } end end + + # Assert that the expected command line args are generated for a given Git::Lib method + # + # This assertion generates an empty git repository and then runs calls + # Git::Base method named by `git_cmd` passing that method `git_cmd_args`. + # + # Before calling `git_cmd`, this method stubs the `Git::Lib#command` method to + # capture the args sent to it by `git_cmd`. These args are captured into + # `actual_command_line`. + # + # assert_equal is called comparing the given `expected_command_line` to + # `actual_command_line`. + # + # @example Fetch with no args + # expected_command_line = ['fetch', '--', 'origin'] + # git_cmd = :fetch + # git_cmd_args = [] + # assert_command_line(expected_command_line, git_cmd, git_cmd_args) + # + # @example Fetch with some args + # expected_command_line = ['fetch', '--depth', '2', '--', 'origin', 'master'] + # git_cmd = :fetch + # git_cmd_args = ['origin', ref: 'master', depth: '2'] + # assert_command_line(expected_command_line, git_cmd, git_cmd_args) + # + # @example Fetch all + # expected_command_line = ['fetch', '--all'] + # git_cmd = :fetch + # git_cmd_args = [all: true] + # assert_command_line(expected_command_line, git_cmd, git_cmd_args) + # + # @param expected_command_line [Array] The expected arguments to be sent to Git::Lib#command + # @param git_cmd [Symbol] the method to be called on the Git::Base object + # @param git_cmd_args [Array] The arguments to be sent to the git_cmd method + # + # @yield [git] An initialization block + # The initialization block is called after a test project is created with Git.init. + # The current working directory is set to the root of the test project's working tree. + # @yieldparam git [Git::Base] The Git::Base object resulting from initializing the test project + # @yieldreturn [void] the return value of the block is ignored + # + # @return [void] + # + def assert_command_line(expected_command_line, git_cmd, git_cmd_args) + actual_command_line = nil + + in_temp_dir do |path| + git = Git.init('test_project') + + Dir.chdir 'test_project' do + yield(git) if block_given? + + # Mock the Git::Lib#command method to capture the actual command line args + git.lib.define_singleton_method(:command) do |cmd, *opts, &block| + actual_command_line = [cmd, *opts.flatten] + end + + git.send(git_cmd, *git_cmd_args) + end + end + + assert_equal(expected_command_line, actual_command_line) + end end diff --git a/tests/units/test_archive.rb b/tests/units/test_archive.rb index 0bd0fc1f..93ec66f2 100644 --- a/tests/units/test_archive.rb +++ b/tests/units/test_archive.rb @@ -7,10 +7,16 @@ class TestArchive < Test::Unit::TestCase def setup set_file_paths @git = Git.open(@wdir) + @tempfiles = [] + end + + def teardown + @tempfiles.clear end def tempfile tempfile_object = Tempfile.new('archive-test') + @tempfiles << tempfile_object # prevent deletion until teardown tempfile_object.close # close to avoid locking from git processes tempfile_object.path end @@ -45,7 +51,7 @@ def test_archive f = @git.object('v2.6').archive(tempfile, :format => 'tar', :prefix => 'test/', :path => 'ex_dir/') assert(File.exist?(f)) - + lines = Minitar::Input.open(f).each.to_a.map(&:full_name) assert_match(%r{test/}, lines[1]) assert_match(%r{test/ex_dir/ex\.txt}, lines[3]) diff --git a/tests/units/test_commit_with_gpg.rb b/tests/units/test_commit_with_gpg.rb new file mode 100644 index 00000000..97fb4de9 --- /dev/null +++ b/tests/units/test_commit_with_gpg.rb @@ -0,0 +1,37 @@ +#!/usr/bin/env ruby + +require File.dirname(__FILE__) + '/../test_helper' + +class TestCommitWithGPG < Test::Unit::TestCase + def setup + set_file_paths + end + + def test_with_configured_gpg_keyid + Dir.mktmpdir do |dir| + git = Git.init(dir) + actual_cmd = nil + git.lib.define_singleton_method(:run_command) do |git_cmd, &block| + actual_cmd = git_cmd + `true` + end + message = 'My commit message' + git.commit(message, gpg_sign: true) + assert_match(/commit.*--gpg-sign['"]/, actual_cmd) + end + end + + def test_with_specific_gpg_keyid + Dir.mktmpdir do |dir| + git = Git.init(dir) + actual_cmd = nil + git.lib.define_singleton_method(:run_command) do |git_cmd, &block| + actual_cmd = git_cmd + `true` + end + message = 'My commit message' + git.commit(message, gpg_sign: 'keykeykey') + assert_match(/commit.*--gpg-sign=keykeykey['"]/, actual_cmd) + end + end +end diff --git a/tests/units/test_config.rb b/tests/units/test_config.rb index a1753831..c04c8530 100644 --- a/tests/units/test_config.rb +++ b/tests/units/test_config.rb @@ -1,6 +1,6 @@ #!/usr/bin/env ruby -require File.dirname(__FILE__) + '/../test_helper' +require_relative '../test_helper' class TestConfig < Test::Unit::TestCase def setup @@ -26,7 +26,20 @@ def test_set_config g.config('user.name', 'bully') assert_equal('bully', g.config('user.name')) end - end + end + + def test_set_config_with_custom_file + in_temp_dir do |_path| + custom_config_path = "#{Dir.pwd}/bare/.git/custom-config" + g = Git.clone(@wbare, 'bare') + assert_not_equal('bully', g.config('user.name')) + g.config('user.name', 'bully', file: custom_config_path) + assert_not_equal('bully', g.config('user.name')) + g.config('include.path', custom_config_path) + assert_equal('bully', g.config('user.name')) + assert_equal("[user]\n\tname = bully\n", File.read(custom_config_path)) + end + end def test_env_config with_custom_env_variables do diff --git a/tests/units/test_describe.rb b/tests/units/test_describe.rb index 580e328e..7dca3a22 100644 --- a/tests/units/test_describe.rb +++ b/tests/units/test_describe.rb @@ -3,14 +3,14 @@ require File.dirname(__FILE__) + '/../test_helper' class TestDescribe < Test::Unit::TestCase - + def setup set_file_paths @git = Git.open(@wdir) end def test_describe - assert_equal(@git.describe(nil, {:tags => true}), 'v2.8') + assert_equal(@git.describe(nil, {:tags => true}), 'grep_colon_numbers') end end diff --git a/tests/units/test_diff_with_escaped_path.rb b/tests/units/test_diff_with_escaped_path.rb new file mode 100644 index 00000000..6387af77 --- /dev/null +++ b/tests/units/test_diff_with_escaped_path.rb @@ -0,0 +1,22 @@ +#!/usr/bin/env ruby +# encoding: utf-8 + +require File.dirname(__FILE__) + '/../test_helper' + +# Test diff when the file path has to be quoted according to core.quotePath +# See https://git-scm.com/docs/git-config#Documentation/git-config.txt-corequotePath +# +class TestDiffWithEscapedPath < Test::Unit::TestCase + def test_diff_with_non_ascii_filename + in_temp_dir do |path| + create_file('my_other_file_☠', "First Line\n") + `git init` + `git add .` + `git config --local core.safecrlf false` if Gem.win_platform? + `git commit -m "First Commit"` + update_file('my_other_file_☠', "Second Line\n") + diff_paths = Git.open('.').diff.map(&:path) + assert_equal(["my_other_file_☠"], diff_paths) + end + end +end diff --git a/tests/units/test_escaped_path.rb b/tests/units/test_escaped_path.rb new file mode 100755 index 00000000..38230e4f --- /dev/null +++ b/tests/units/test_escaped_path.rb @@ -0,0 +1,36 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "#{File.dirname(__FILE__)}/../test_helper" + +# Test diff when the file path has escapes according to core.quotePath +# See https://git-scm.com/docs/git-config#Documentation/git-config.txt-corequotePath +# See https://www.jvt.me/posts/2020/06/23/byte-array-to-string-ruby/ +# See https://stackoverflow.com/questions/54788845/how-can-i-convert-a-guid-into-a-byte-array-in-ruby +# +class TestEscapedPath < Test::Unit::TestCase + def test_simple_path + path = 'my_other_file' + expected_unescaped_path = 'my_other_file' + assert_equal(expected_unescaped_path, Git::EscapedPath.new(path).unescape) + end + + def test_unicode_path + path = 'my_other_file_\\342\\230\\240' + expected_unescaped_path = 'my_other_file_☠' + assert_equal(expected_unescaped_path, Git::EscapedPath.new(path).unescape) + end + + def test_single_char_escapes + Git::EscapedPath::UNESCAPES.each_pair do |escape_char, expected_char| + path = "\\#{escape_char}" + assert_equal(expected_char.chr, Git::EscapedPath.new(path).unescape) + end + end + + def test_compound_escape + path = 'my_other_file_"\\342\\230\\240\\n"' + expected_unescaped_path = "my_other_file_\"☠\n\"" + assert_equal(expected_unescaped_path, Git::EscapedPath.new(path).unescape) + end +end diff --git a/tests/units/test_git_alt_uri.rb b/tests/units/test_git_alt_uri.rb new file mode 100644 index 00000000..b01ea1bb --- /dev/null +++ b/tests/units/test_git_alt_uri.rb @@ -0,0 +1,27 @@ +require 'test/unit' + +# Tests for the Git::GitAltURI class +# +class TestGitAltURI < Test::Unit::TestCase + def test_new + uri = Git::GitAltURI.new(user: 'james', host: 'github.com', path: 'ruby-git/ruby-git.git') + actual_attributes = uri.to_hash.delete_if { |_key, value| value.nil? } + expected_attributes = { + scheme: 'git-alt', + user: 'james', + host: 'github.com', + path: '/ruby-git/ruby-git.git' + } + assert_equal(expected_attributes, actual_attributes) + end + + def test_to_s + uri = Git::GitAltURI.new(user: 'james', host: 'github.com', path: 'ruby-git/ruby-git.git') + assert_equal('james@github.com:ruby-git/ruby-git.git', uri.to_s) + end + + def test_to_s_with_nil_user + uri = Git::GitAltURI.new(user: nil, host: 'github.com', path: 'ruby-git/ruby-git.git') + assert_equal('github.com:ruby-git/ruby-git.git', uri.to_s) + end +end diff --git a/tests/units/test_git_clone.rb b/tests/units/test_git_clone.rb new file mode 100644 index 00000000..8a5d1806 --- /dev/null +++ b/tests/units/test_git_clone.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'test/unit' +require_relative '../test_helper' + +# Tests for Git.clone +class TestGitClone < Test::Unit::TestCase + def setup_repo + Git.init('repository.git', bare: true) + git = Git.clone('repository.git', 'temp') + File.write('temp/test.txt', 'test') + git.add('test.txt') + git.commit('Initial commit') + end + + def test_git_clone_with_name + in_temp_dir do |path| + setup_repo + clone_dir = 'clone_to_this_dir' + git = Git.clone('repository.git', clone_dir) + assert(Dir.exist?(clone_dir)) + expected_dir = File.realpath(clone_dir) + assert_equal(expected_dir, git.dir.to_s) + end + end + + def test_git_clone_with_no_name + in_temp_dir do |path| + setup_repo + git = Git.clone('repository.git') + assert(Dir.exist?('repository')) + expected_dir = File.realpath('repository') + assert_equal(expected_dir, git.dir.to_s) + end + end +end diff --git a/tests/units/test_git_dir.rb b/tests/units/test_git_dir.rb index 551b0f34..8034d859 100644 --- a/tests/units/test_git_dir.rb +++ b/tests/units/test_git_dir.rb @@ -84,7 +84,7 @@ def test_git_diff_to_a Dir.chdir(work_tree) do `git init` `git commit --allow-empty -m 'init'` - `git worktree add child` + `git worktree add --quiet child` Dir.chdir('child') do result = Git.open('.').diff.to_a assert_equal([], result) diff --git a/tests/units/test_index_ops.rb b/tests/units/test_index_ops.rb index fd47e609..c033735b 100644 --- a/tests/units/test_index_ops.rb +++ b/tests/units/test_index_ops.rb @@ -49,6 +49,11 @@ def test_clean g.add g.commit("first commit") + FileUtils.mkdir_p("nested") + Dir.chdir('nested') do + Git.init + end + new_file('file-to-clean', 'blablahbla') FileUtils.mkdir_p("dir_to_clean") @@ -76,6 +81,11 @@ def test_clean g.clean(:force => true, :x => true) assert(!File.exist?('ignored_file')) + + assert(File.exist?('nested')) + + g.clean(:ff => true, :d => true) + assert(!File.exist?('nested')) end end end diff --git a/tests/units/test_init.rb b/tests/units/test_init.rb index bbb04b94..4ec4771d 100644 --- a/tests/units/test_init.rb +++ b/tests/units/test_init.rb @@ -38,14 +38,14 @@ def test_git_init assert(File.directory?(File.join(path, '.git'))) assert(File.exist?(File.join(path, '.git', 'config'))) assert_equal('false', repo.config('core.bare')) + assert_equal("ref: refs/heads/master\n", File.read("#{path}/.git/HEAD")) end end def test_git_init_bare in_temp_dir do |path| repo = Git.init(path, :bare => true) - assert(File.directory?(File.join(path, '.git'))) - assert(File.exist?(File.join(path, '.git', 'config'))) + assert(File.exist?(File.join(path, 'config'))) assert_equal('true', repo.config('core.bare')) end end @@ -61,6 +61,16 @@ def test_git_init_remote_git end end + def test_git_init_initial_branch + in_temp_dir do |path| + repo = Git.init(path, initial_branch: 'main') + assert(File.directory?(File.join(path, '.git'))) + assert(File.exist?(File.join(path, '.git', 'config'))) + assert_equal('false', repo.config('core.bare')) + assert_equal("ref: refs/heads/main\n", File.read("#{path}/.git/HEAD")) + end + end + def test_git_clone in_temp_dir do |path| g = Git.clone(@wbare, 'bare-co') diff --git a/tests/units/test_lib.rb b/tests/units/test_lib.rb index 8c8c420d..bdf50a75 100644 --- a/tests/units/test_lib.rb +++ b/tests/units/test_lib.rb @@ -19,7 +19,7 @@ def test_fetch_unshallow git = Git.clone("file://#{@wdir}", "shallow", path: dir, depth: 1).lib assert_equal(1, git.log_commits.length) git.fetch("file://#{@wdir}", unshallow: true) - assert_equal(71, git.log_commits.length) + assert_equal(72, git.log_commits.length) end end @@ -282,10 +282,14 @@ def test_grep match = @lib.grep('search', :object => 'gitsearch1', :invert_match => true) assert_equal(6, match['gitsearch1:scott/text.txt'].size) assert_equal(2, match.size) + + match = @lib.grep('Grep', :object => 'grep_colon_numbers') + assert_equal("Grep regex doesn't like this:4342: because it is bad", match['grep_colon_numbers:colon_numbers.txt'].first[1]) + assert_equal(1, match.size) end def test_show - assert(/^commit 5e53019b3238362144c2766f02a2c00d91fcc023.+\+replace with new text - diff test$/m.match(@lib.show)) + assert_match(/^commit 46abbf07e3c564c723c7c039a43ab3a39e5d02dd.+\+Grep regex doesn't like this:4342: because it is bad\n$/m, @lib.show) assert(/^commit 935badc874edd62a8629aaf103418092c73f0a56.+\+nothing!$/m.match(@lib.show('gitsearch1'))) assert(/^hello.+nothing!$/m.match(@lib.show('gitsearch1', 'scott/text.txt'))) assert(@lib.show('gitsearch1', 'scott/text.txt') == "hello\nthis is\na file\nthat is\nput here\nto search one\nto search two\nnothing!\n") diff --git a/tests/units/test_log.rb b/tests/units/test_log.rb index 81c6e300..4a947842 100644 --- a/tests/units/test_log.rb +++ b/tests/units/test_log.rb @@ -12,13 +12,13 @@ def setup def test_get_fisrt_and_last_entries log = @git.log assert(log.first.is_a?(Git::Object::Commit)) - assert_equal('5e53019b3238362144c2766f02a2c00d91fcc023', log.first.objectish) + assert_equal('46abbf07e3c564c723c7c039a43ab3a39e5d02dd', log.first.objectish) assert(log.last.is_a?(Git::Object::Commit)) - assert_equal('f1410f8735f6f73d3599eb9b5cdd2fb70373335c', log.last.objectish) + assert_equal('b03003311ad3fa368b475df58390353868e13c91', log.last.objectish) end - - def test_get_log_entries + + def test_get_log_entries assert_equal(30, @git.log.size) assert_equal(50, @git.log(50).size) assert_equal(10, @git.log(10).size) @@ -35,15 +35,15 @@ def test_log_skip assert_equal(three2.sha, three3.sha) assert_equal(three1.sha, three2.sha) end - + def test_get_log_since l = @git.log.since("2 seconds ago") assert_equal(0, l.size) - + l = @git.log.since("#{Date.today.year - 2006} years ago") assert_equal(30, l.size) end - + def test_get_log_grep l = @git.log.grep("search") assert_equal(2, l.size) @@ -55,11 +55,11 @@ def test_get_log_author l = @git.log(5).author("lazySusan") assert_equal(0, l.size) end - - def test_get_log_since_file + + def test_get_log_since_file l = @git.log.path('example.txt') assert_equal(30, l.size) - + l = @git.log.between('v2.5', 'test').path('example.txt') assert_equal(1, l.size) end @@ -72,7 +72,7 @@ def test_get_log_path log = @git.log.path(['example.txt','scott/text.txt']) assert_equal(30, log.size) end - + def test_log_file_noexist assert_raise Git::GitExecuteError do @git.log.object('no-exist.txt').size @@ -96,5 +96,5 @@ def test_log_cherry l = @git.log.between( 'master', 'cherry').cherry assert_equal( 1, l.size ) end - + end diff --git a/tests/units/test_logger.rb b/tests/units/test_logger.rb index 0f72cc95..954c5e0c 100644 --- a/tests/units/test_logger.rb +++ b/tests/units/test_logger.rb @@ -7,32 +7,49 @@ class TestLogger < Test::Unit::TestCase def setup set_file_paths end - + + def missing_log_entry + 'Did not find expected log entry.' + end + + def unexpected_log_entry + 'Unexpected log entry found' + end + def test_logger log = Tempfile.new('logfile') log.close - + logger = Logger.new(log.path) logger.level = Logger::DEBUG - + @git = Git.open(@wdir, :log => logger) @git.branches.size - + logc = File.read(log.path) - assert(/INFO -- : git ['"]--git-dir=[^'"]+['"] ['"]--work-tree=[^'"]+['"] ['"]-c['"] ['"]color.ui=false['"] branch ['"]-a['"]/.match(logc)) - assert(/DEBUG -- : cherry\n diff_over_patches\n\* git_grep/m.match(logc)) + expected_log_entry = /INFO -- : git (?.*?) branch ['"]-a['"]/ + assert_match(expected_log_entry, logc, missing_log_entry) + + expected_log_entry = /DEBUG -- : cherry/ + assert_match(expected_log_entry, logc, missing_log_entry) + end + + def test_logging_at_info_level_should_not_show_debug_messages log = Tempfile.new('logfile') log.close logger = Logger.new(log.path) logger.level = Logger::INFO - + @git = Git.open(@wdir, :log => logger) @git.branches.size - + logc = File.read(log.path) - assert(/INFO -- : git ['"]--git-dir=[^'"]+['"] ['"]--work-tree=[^'"]+['"] ['"]-c['"] ['"]color.ui=false['"] branch ['"]-a['"]/.match(logc)) - assert(!/DEBUG -- : cherry\n diff_over_patches\n\* git_grep/m.match(logc)) + + expected_log_entry = /INFO -- : git (?.*?) branch ['"]-a['"]/ + assert_match(expected_log_entry, logc, missing_log_entry) + + expected_log_entry = /DEBUG -- : cherry/ + assert_not_match(expected_log_entry, logc, unexpected_log_entry) end - end diff --git a/tests/units/test_merge.rb b/tests/units/test_merge.rb index 8fd10712..21e9ee78 100644 --- a/tests/units/test_merge.rb +++ b/tests/units/test_merge.rb @@ -130,4 +130,33 @@ def test_no_ff_merge end end end + + def test_merge_no_commit + in_temp_dir do |path| + g = Git.clone(@wbare, 'branch_merge_test') + g.chdir do + g.branch('new_branch_1').in_branch('first commit message') do + new_file('new_file_1', 'foo') + g.add + true + end + + g.branch('new_branch_2').in_branch('first commit message') do + new_file('new_file_2', 'bar') + g.add + true + end + + g.checkout('new_branch_2') + before_merge = g.show + g.merge('new_branch_1', nil, no_commit: true) + # HEAD is the same as before. + assert_equal(before_merge, g.show) + # File has not been merged in. + status = g.status['new_file_1'] + assert_equal('new_file_1', status.path) + assert_equal('A', status.type) + end + end + end end diff --git a/tests/units/test_remotes.rb b/tests/units/test_remotes.rb index 3e90c3b5..ab8f6f85 100644 --- a/tests/units/test_remotes.rb +++ b/tests/units/test_remotes.rb @@ -1,6 +1,6 @@ #!/usr/bin/env ruby -require File.dirname(__FILE__) + '/../test_helper' +require_relative '../test_helper' class TestRemotes < Test::Unit::TestCase def setup @@ -23,29 +23,29 @@ def test_add_remote assert(local.remotes.map{|b| b.name}.include?('testremote2')) local.add_remote('testremote3', remote, :track => 'master') - - assert(local.branches.map{|b| b.full}.include?('master')) #We actually a new branch ('test_track') on the remote and track that one intead. + + assert(local.branches.map{|b| b.full}.include?('master')) #We actually a new branch ('test_track') on the remote and track that one intead. assert(local.remotes.map{|b| b.name}.include?('testremote3')) - end + end end def test_remove_remote_remove in_temp_dir do |path| local = Git.clone(@wbare, 'local') remote = Git.clone(@wbare, 'remote') - + local.add_remote('testremote', remote) local.remove_remote('testremote') - + assert(!local.remotes.map{|b| b.name}.include?('testremote')) local.add_remote('testremote', remote) local.remote('testremote').remove - + assert(!local.remotes.map{|b| b.name}.include?('testremote')) end end - + def test_set_remote_url in_temp_dir do |path| local = Git.clone(@wbare, 'local') @@ -65,33 +65,33 @@ def test_remote_fun in_temp_dir do |path| loc = Git.clone(@wbare, 'local') rem = Git.clone(@wbare, 'remote') - + r = loc.add_remote('testrem', rem) Dir.chdir('remote') do new_file('test-file1', 'blahblahblah1') rem.add rem.commit('master commit') - + rem.branch('testbranch').in_branch('tb commit') do new_file('test-file3', 'blahblahblah3') rem.add - true + true end end assert(!loc.status['test-file1']) assert(!loc.status['test-file3']) - + r.fetch - r.merge + r.merge assert(loc.status['test-file1']) - + loc.merge(loc.remote('testrem').branch('testbranch')) - assert(loc.status['test-file3']) - + assert(loc.status['test-file3']) + #puts loc.remotes.map { |r| r.to_s }.inspect - - #r.remove + + #r.remove #puts loc.remotes.inspect end end @@ -123,18 +123,65 @@ def test_fetch end end + def test_fetch_cmd_with_no_args + expected_command_line = ['fetch', '--', 'origin'] + git_cmd = :fetch + git_cmd_args = [] + assert_command_line(expected_command_line, git_cmd, git_cmd_args) + end + + def test_fetch_cmd_with_origin_and_branch + expected_command_line = ['fetch', '--depth', '2', '--', 'origin', 'master'] + git_cmd = :fetch + git_cmd_args = ['origin', ref: 'master', depth: '2'] + assert_command_line(expected_command_line, git_cmd, git_cmd_args) + end + + def test_fetch_cmd_with_all + expected_command_line = ['fetch', '--all'] + git_cmd = :fetch + git_cmd_args = [all: true] + assert_command_line(expected_command_line, git_cmd, git_cmd_args) + end + + def test_fetch_cmd_with_all_with_other_args + expected_command_line = ['fetch', '--all', '--force', '--depth', '2'] + git_cmd = :fetch + git_cmd_args = [all: true, force: true, depth: '2'] + assert_command_line(expected_command_line, git_cmd, git_cmd_args) + end + + def test_fetch_command_injection + test_file = 'VULNERABILITY_EXISTS' + vulnerability_exists = false + in_temp_dir do |_path| + git = Git.init('test_project') + origin = "--upload-pack=touch #{test_file};" + begin + git.fetch(origin, { ref: 'some/ref/head' }) + rescue Git::GitExecuteError + # This is expected + else + raise 'Expected Git::GitExecuteError to be raised' + end + + vulnerability_exists = File.exist?(test_file) + end + assert(!vulnerability_exists) + end + def test_fetch_ref_adds_ref_option in_temp_dir do |path| loc = Git.clone(@wbare, 'local') rem = Git.clone(@wbare, 'remote', :config => 'receive.denyCurrentBranch=ignore') loc.add_remote('testrem', rem) - + loc.chdir do new_file('test-file1', 'gonnaCommitYou') loc.add loc.commit('master commit 1') first_commit_sha = loc.log.first.sha - + new_file('test-file2', 'gonnaCommitYouToo') loc.add loc.commit('master commit 2') @@ -146,16 +193,16 @@ def test_fetch_ref_adds_ref_option # Make sure fetch message only has the second commit when we fetch the second commit assert(loc.fetch('origin', {:ref => second_commit_sha}).include?(second_commit_sha)) - assert(!loc.fetch('origin', {:ref => second_commit_sha}).include?(first_commit_sha)) - end + assert(!loc.fetch('origin', {:ref => second_commit_sha}).include?(first_commit_sha)) + end end end - + def test_push in_temp_dir do |path| loc = Git.clone(@wbare, 'local') rem = Git.clone(@wbare, 'remote', :config => 'receive.denyCurrentBranch=ignore') - + loc.add_remote('testrem', rem) loc.chdir do @@ -163,32 +210,30 @@ def test_push loc.add loc.commit('master commit') loc.add_tag('test-tag') - + loc.branch('testbranch').in_branch('tb commit') do new_file('test-file3', 'blahblahblah3') loc.add - true + true end end assert(!rem.status['test-file1']) assert(!rem.status['test-file3']) - + loc.push('testrem') - assert(rem.status['test-file1']) - assert(!rem.status['test-file3']) + assert(rem.status['test-file1']) + assert(!rem.status['test-file3']) assert_raise Git::GitTagNameDoesNotExist do rem.tag('test-tag') end - + loc.push('testrem', 'testbranch', true) rem.checkout('testbranch') - assert(rem.status['test-file1']) - assert(rem.status['test-file3']) + assert(rem.status['test-file1']) + assert(rem.status['test-file3']) assert(rem.tag('test-tag')) end end - - end diff --git a/tests/units/test_status.rb b/tests/units/test_status.rb index 2a2e7836..964a59ae 100644 --- a/tests/units/test_status.rb +++ b/tests/units/test_status.rb @@ -12,7 +12,9 @@ def setup def test_status_pretty in_temp_dir do |path| git = Git.clone(@wdir, 'test_dot_files_status') - string = "ex_dir/ex.txt\n\tsha(r) \n\tsha(i) e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 " \ + string = "colon_numbers.txt\n\tsha(r) \n\tsha(i) " \ + "e76778b73006b0dda0dd56e9257c5bf6b6dd3373 100644\n\ttype \n\tstage 0\n\tuntrac \n" \ + "ex_dir/ex.txt\n\tsha(r) \n\tsha(i) e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 " \ "100644\n\ttype \n\tstage 0\n\tuntrac \nexample.txt\n\tsha(r) \n\tsha(i) " \ "8dc79ae7616abf1e2d4d5d97d566f2b2f6cee043 100644\n\ttype \n\tstage 0\n\tuntrac " \ "\nscott/newfile\n\tsha(r) \n\tsha(i) 5d4606820736043f9eed2a6336661d6892c820a5 " \ diff --git a/tests/units/test_thread_safety.rb b/tests/units/test_thread_safety.rb index 401659a5..d2500f10 100644 --- a/tests/units/test_thread_safety.rb +++ b/tests/units/test_thread_safety.rb @@ -24,7 +24,7 @@ def test_git_init_bare threads.each(&:join) dirs.each do |dir| - Git.bare("#{dir}/.git").ls_files + Git.bare(dir).ls_files end end end diff --git a/tests/units/test_url_clone_to.rb b/tests/units/test_url_clone_to.rb new file mode 100644 index 00000000..6f5c9e82 --- /dev/null +++ b/tests/units/test_url_clone_to.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'test/unit' +require File.join(File.dirname(__dir__), 'test_helper') + +# Tests Git::URL.clone_to +# +class TestURLCloneTo < Test::Unit::TestCase + def test_clone_to_full_repo + GIT_URLS.each do |url_data| + url = url_data[:url] + expected_path = url_data[:expected_path] + actual_path = Git::URL.clone_to(url) + assert_equal( + expected_path, actual_path, + "Failed to determine the clone path for URL '#{url}' correctly" + ) + end + end + + def test_clone_to_bare_repo + GIT_URLS.each do |url_data| + url = url_data[:url] + expected_path = url_data[:expected_bare_path] + actual_path = Git::URL.clone_to(url, bare: true) + assert_equal( + expected_path, actual_path, + "Failed to determine the clone path for URL '#{url}' correctly" + ) + end + end + + def test_clone_to_mirror_repo + GIT_URLS.each do |url_data| + url = url_data[:url] + # The expected_path is the same for bare and mirror repos + expected_path = url_data[:expected_bare_path] + actual_path = Git::URL.clone_to(url, mirror: true) + assert_equal( + expected_path, actual_path, + "Failed to determine the clone path for URL '#{url}' correctly" + ) + end + end + + GIT_URLS = [ + { + url: 'https://github.com/org/repo', + expected_path: 'repo', + expected_bare_path: 'repo.git' + }, + { + url: 'https://github.com/org/repo.git', + expected_path: 'repo', + expected_bare_path: 'repo.git' + }, + { + url: 'https://git.mydomain.com/org/repo/.git', + expected_path: 'repo', + expected_bare_path: 'repo.git' + } + ].freeze + + # Git::URL.clone_to makes some assumptions about how the `git` command names + # the directory to clone to. This test ensures that the assumptions are + # correct. + # + def test_git_clone_naming_assumptions + in_temp_dir do |_path| + setup_test_repositories + + GIT_CLONE_COMMANDS.each do |command_data| + command = command_data[:command] + expected_path = command_data[:expected_path] + + output = `#{command} 2>&1` + + assert_match(/Cloning into (?:bare repository )?'#{expected_path}'/, output) + FileUtils.rm_rf(expected_path) + end + end + end + + GIT_CLONE_COMMANDS = [ + # Clone to full repository + { command: 'git clone server/my_project', expected_path: 'my_project' }, + { command: 'git clone server/my_project/.git', expected_path: 'my_project' }, + { command: 'git clone server/my_project.git', expected_path: 'my_project' }, + + # Clone to bare repository + { command: 'git clone --bare server/my_project', expected_path: 'my_project.git' }, + { command: 'git clone --bare server/my_project/.git', expected_path: 'my_project.git' }, + { command: 'git clone --bare server/my_project.git', expected_path: 'my_project.git' }, + + # Clone to mirror repository + { command: 'git clone --mirror server/my_project', expected_path: 'my_project.git' }, + { command: 'git clone --mirror server/my_project/.git', expected_path: 'my_project.git' }, + { command: 'git clone --mirror server/my_project.git', expected_path: 'my_project.git' } + ].freeze + + def setup_test_repositories + # Create a repository to clone from + Dir.mkdir 'server' + remote = Git.init('server/my_project') + Dir.chdir('server/my_project') do + new_file('README.md', '# My New Project') + remote.add + remote.commit('Initial version') + end + + # Create a bare repository to clone from + Git.clone('server/my_project', 'server/my_project.git', bare: true) + end +end diff --git a/tests/units/test_url_parse.rb b/tests/units/test_url_parse.rb new file mode 100644 index 00000000..2ca97333 --- /dev/null +++ b/tests/units/test_url_parse.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'test/unit' + +# Tests Git::URL.parse +# +class TestURLParse < Test::Unit::TestCase + def test_parse_with_invalid_url + url = 'user@host.xz:/path/to/repo.git/' + assert_raise(Addressable::URI::InvalidURIError) do + Git::URL.parse(url) + end + end + + def test_parse + GIT_URLS.each do |url_data| + url = url_data[:url] + expected_uri = url_data[:expected_uri] + actual_uri = Git::URL.parse(url).to_hash.delete_if { |_key, value| value.nil? } + assert_equal(expected_uri, actual_uri, "Failed to parse URL '#{url}' correctly") + end + end + + # For any URL, #to_s should return the url passed to Git::URL.parse(url) + def test_to_s + GIT_URLS.each do |url_data| + url = url_data[:url] + to_s = Git::URL.parse(url).to_s + assert_equal(url, to_s, "Parsed URI#to_s does not return the original URL '#{url}' correctly") + end + end + + GIT_URLS = [ + { + url: 'ssh://host.xz/path/to/repo.git/', + expected_uri: { scheme: 'ssh', host: 'host.xz', path: '/path/to/repo.git/' } + }, + { + url: 'ssh://host.xz:4443/path/to/repo.git/', + expected_uri: { scheme: 'ssh', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' } + }, + { + url: 'ssh:///path/to/repo.git/', + expected_uri: { scheme: 'ssh', host: '', path: '/path/to/repo.git/' } + }, + { + url: 'user@host.xz:path/to/repo.git/', + expected_uri: { scheme: 'git-alt', user: 'user', host: 'host.xz', path: '/path/to/repo.git/' } + }, + { + url: 'host.xz:path/to/repo.git/', + expected_uri: { scheme: 'git-alt', host: 'host.xz', path: '/path/to/repo.git/' } + }, + { + url: 'git://host.xz:4443/path/to/repo.git/', + expected_uri: { scheme: 'git', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' } + }, + { + url: 'git://user@host.xz:4443/path/to/repo.git/', + expected_uri: { scheme: 'git', user: 'user', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' } + }, + { + url: 'https://host.xz/path/to/repo.git/', + expected_uri: { scheme: 'https', host: 'host.xz', path: '/path/to/repo.git/' } + }, + { + url: 'https://host.xz:4443/path/to/repo.git/', + expected_uri: { scheme: 'https', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' } + }, + { + url: 'ftps://host.xz:4443/path/to/repo.git/', + expected_uri: { scheme: 'ftps', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' } + }, + { + url: 'ftps://host.xz:4443/path/to/repo.git/', + expected_uri: { scheme: 'ftps', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' } + }, + { + url: 'file:./relative-path/to/repo.git/', + expected_uri: { scheme: 'file', path: './relative-path/to/repo.git/' } + }, + { + url: 'file:///path/to/repo.git/', + expected_uri: { scheme: 'file', host: '', path: '/path/to/repo.git/' } + }, + { + url: 'file:///path/to/repo.git', + expected_uri: { scheme: 'file', host: '', path: '/path/to/repo.git' } + }, + { + url: 'file://host.xz/path/to/repo.git', + expected_uri: { scheme: 'file', host: 'host.xz', path: '/path/to/repo.git' } + }, + { url: '/path/to/repo.git/', expected_uri: { path: '/path/to/repo.git/' } }, + { url: '/path/to/bare-repo/.git', expected_uri: { path: '/path/to/bare-repo/.git' } }, + { url: 'relative-path/to/repo.git/', expected_uri: { path: 'relative-path/to/repo.git/' } }, + { url: './relative-path/to/repo.git/', expected_uri: { path: './relative-path/to/repo.git/' } }, + { url: '../ruby-git/.git', expected_uri: { path: '../ruby-git/.git' } } + ].freeze +end diff --git a/tests/units/test_windows_cmd_escaping.rb b/tests/units/test_windows_cmd_escaping.rb new file mode 100644 index 00000000..a5d994d9 --- /dev/null +++ b/tests/units/test_windows_cmd_escaping.rb @@ -0,0 +1,22 @@ +#!/usr/bin/env ruby +# encoding: utf-8 + +require File.dirname(__FILE__) + '/../test_helper' + +# Test diff when the file path has to be quoted according to core.quotePath +# See https://git-scm.com/docs/git-config#Documentation/git-config.txt-corequotePath +# +class TestWindowsCmdEscaping < Test::Unit::TestCase + def test_commit_with_double_quote_in_commit_message + expected_commit_message = 'Commit message with "double quotes"' + in_temp_dir do |path| + create_file('README.md', "# README\n") + git = Git.init('.') + git.add + git.commit(expected_commit_message) + commits = git.log(1) + actual_commit_message = commits.first.message + assert_equal(expected_commit_message, actual_commit_message) + end + end +end diff --git a/tests/units/test_worktree.rb b/tests/units/test_worktree.rb index 2b509726..c0a81dcb 100644 --- a/tests/units/test_worktree.rb +++ b/tests/units/test_worktree.rb @@ -1,5 +1,6 @@ #!/usr/bin/env ruby require 'fileutils' +require 'pathname' require File.dirname(__FILE__) + '/../test_helper' SAMPLE_LAST_COMMIT = '5e53019b3238362144c2766f02a2c00d91fcc023' @@ -32,23 +33,24 @@ def create_temp_repo(clone_path) def setup @git = Git.open(git_working_dir) - + @commit = @git.object('1cc8667014381') @tree = @git.object('1cc8667014381^{tree}') @blob = @git.object('v2.5:example.txt') - + @worktrees = @git.worktrees end - + def test_worktrees_all assert(@git.worktrees.is_a?(Git::Worktrees)) assert(@git.worktrees.first.is_a?(Git::Worktree)) assert_equal(@git.worktrees.size, 2) end - + def test_worktrees_single worktree = @git.worktrees.first - assert_equal(worktree.dir, @git.dir.to_s) + git_dir = Pathname.new(@git.dir.to_s).realpath.to_s + assert_equal(worktree.dir, git_dir) assert_equal(worktree.gcommit, SAMPLE_LAST_COMMIT) end