diff --git a/CHANGELOG.md b/CHANGELOG.md index 175fdac..662ecc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## [Unreleased] +## [0.6.0] - 2025-07-25 + +- Add `--only` option to only include specific files or directories in the diff. +- Add `rails-diff dotfiles` to compare dotfiles (configuration files) in the repository. +- [BUGFIX] --fail-on-diff wasn't aborting with errors on diff. + ## [0.5.0] - 2025-03-10 - Don't abort process on bundle check failure. @@ -64,6 +70,9 @@ M## [0.1.1] - 2025-02-21 - Initial release +[0.6.0]: https://github.com/matheusrich/rails-diff/releases/tag/v0.6.0 +[0.5.0]: https://github.com/matheusrich/rails-diff/releases/tag/v0.5.0 +[0.4.1]: https://github.com/matheusrich/rails-diff/releases/tag/v0.4.1 [0.4.1]: https://github.com/matheusrich/rails-diff/releases/tag/v0.4.1 [0.4.0]: https://github.com/matheusrich/rails-diff/releases/tag/v0.4.0 [0.3.0]: https://github.com/matheusrich/rails-diff/releases/tag/v0.3.0 diff --git a/Gemfile b/Gemfile index aa82799..e2ec273 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,8 @@ source "https://rubygems.org/" # Specify your gem's dependencies in rails-diff.gemspec gemspec +gem "simplecov", require: false +gem "standard", "~> 1.0" gem "rake", "~> 13.0" gem "rspec", "~> 3.0" diff --git a/Gemfile.lock b/Gemfile.lock index 6cc3972..d49748b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - rails-diff (0.5.0) + rails-diff (0.6.0) diffy (~> 3.4) rails (>= 7.0) thor (~> 1.0) @@ -9,29 +9,29 @@ PATH GEM remote: https://rubygems.org/ specs: - actioncable (8.0.1) - actionpack (= 8.0.1) - activesupport (= 8.0.1) + actioncable (8.0.2.1) + actionpack (= 8.0.2.1) + activesupport (= 8.0.2.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.1) - actionpack (= 8.0.1) - activejob (= 8.0.1) - activerecord (= 8.0.1) - activestorage (= 8.0.1) - activesupport (= 8.0.1) + actionmailbox (8.0.2.1) + actionpack (= 8.0.2.1) + activejob (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) mail (>= 2.8.0) - actionmailer (8.0.1) - actionpack (= 8.0.1) - actionview (= 8.0.1) - activejob (= 8.0.1) - activesupport (= 8.0.1) + actionmailer (8.0.2.1) + actionpack (= 8.0.2.1) + actionview (= 8.0.2.1) + activejob (= 8.0.2.1) + activesupport (= 8.0.2.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.1) - actionview (= 8.0.1) - activesupport (= 8.0.1) + actionpack (8.0.2.1) + actionview (= 8.0.2.1) + activesupport (= 8.0.2.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -39,35 +39,35 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.1) - actionpack (= 8.0.1) - activerecord (= 8.0.1) - activestorage (= 8.0.1) - activesupport (= 8.0.1) + actiontext (8.0.2.1) + actionpack (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.1) - activesupport (= 8.0.1) + actionview (8.0.2.1) + activesupport (= 8.0.2.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.0.1) - activesupport (= 8.0.1) + activejob (8.0.2.1) + activesupport (= 8.0.2.1) globalid (>= 0.3.6) - activemodel (8.0.1) - activesupport (= 8.0.1) - activerecord (8.0.1) - activemodel (= 8.0.1) - activesupport (= 8.0.1) + activemodel (8.0.2.1) + activesupport (= 8.0.2.1) + activerecord (8.0.2.1) + activemodel (= 8.0.2.1) + activesupport (= 8.0.2.1) timeout (>= 0.4.0) - activestorage (8.0.1) - actionpack (= 8.0.1) - activejob (= 8.0.1) - activerecord (= 8.0.1) - activesupport (= 8.0.1) + activestorage (8.0.2.1) + actionpack (= 8.0.2.1) + activejob (= 8.0.2.1) + activerecord (= 8.0.2.1) + activesupport (= 8.0.2.1) marcel (~> 1.0) - activesupport (8.0.1) + activesupport (8.0.2.1) base64 benchmark (>= 0.3) bigdecimal @@ -80,17 +80,19 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - base64 (0.2.0) - benchmark (0.4.0) - bigdecimal (3.1.9) + ast (2.4.3) + base64 (0.3.0) + benchmark (0.4.1) + bigdecimal (3.2.2) builder (3.3.0) concurrent-ruby (1.3.5) - connection_pool (2.5.0) + connection_pool (2.5.3) crass (1.0.6) date (3.4.1) diff-lcs (1.5.1) diffy (3.4.3) - drb (2.2.1) + docile (1.4.1) + drb (2.2.3) erubi (1.13.1) globalid (1.2.1) activesupport (>= 6.1) @@ -101,7 +103,10 @@ GEM pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - logger (1.6.6) + json (2.12.2) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) loofah (2.24.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -112,8 +117,8 @@ GEM net-smtp marcel (1.0.4) mini_mime (1.1.5) - mini_portile2 (2.8.8) - minitest (5.25.4) + mini_portile2 (2.8.9) + minitest (5.25.5) net-imap (0.5.7) date net-protocol @@ -124,19 +129,24 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.8) + nokogiri (1.18.9) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.8-x86_64-darwin) + nokogiri (1.18.9-x86_64-darwin) racc (~> 1.4) + parallel (1.27.0) + parser (3.3.8.0) + ast (~> 2.4.1) + racc pp (0.6.2) prettyprint prettyprint (0.2.0) + prism (1.4.0) psych (5.2.3) date stringio racc (1.8.1) - rack (3.1.16) + rack (3.1.18) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -144,20 +154,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (8.0.1) - actioncable (= 8.0.1) - actionmailbox (= 8.0.1) - actionmailer (= 8.0.1) - actionpack (= 8.0.1) - actiontext (= 8.0.1) - actionview (= 8.0.1) - activejob (= 8.0.1) - activemodel (= 8.0.1) - activerecord (= 8.0.1) - activestorage (= 8.0.1) - activesupport (= 8.0.1) + rails (8.0.2.1) + actioncable (= 8.0.2.1) + actionmailbox (= 8.0.2.1) + actionmailer (= 8.0.2.1) + actionpack (= 8.0.2.1) + actiontext (= 8.0.2.1) + actionview (= 8.0.2.1) + activejob (= 8.0.2.1) + activemodel (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) bundler (>= 1.15.0) - railties (= 8.0.1) + railties (= 8.0.2.1) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -165,17 +175,19 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (8.0.1) - actionpack (= 8.0.1) - activesupport (= 8.0.1) + railties (8.0.2.1) + actionpack (= 8.0.2.1) + activesupport (= 8.0.2.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) + rainbow (3.1.1) rake (13.2.1) rdoc (6.12.0) psych (>= 4.0.0) + regexp_parser (2.10.0) reline (0.6.0) io-console (~> 0.5) rspec (3.13.0) @@ -191,12 +203,52 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.2) + rubocop (1.75.8) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.44.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.45.1) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-performance (1.25.0) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + ruby-progressbar (1.13.0) securerandom (0.4.1) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.1) + simplecov_json_formatter (0.1.4) + standard (1.50.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.75.5) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.8.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.25.0) stringio (3.1.4) - thor (1.3.2) + thor (1.4.0) timeout (0.4.3) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) uri (1.0.3) useragent (0.16.11) websocket-driver (0.7.7) @@ -213,6 +265,8 @@ DEPENDENCIES rails-diff! rake (~> 13.0) rspec (~> 3.0) + simplecov + standard (~> 1.0) BUNDLED WITH 2.6.2 diff --git a/README.md b/README.md index 5e0ac88..9deb309 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,14 @@ rails-diff generated scaffold Post --fail-on-diff rails-diff generated authentication --commit 7df1b8 ``` -### Options +### Compare dotfiles (configuration files) + +```bash +# Compare configuration files like .gitignore, .rspec, .rubocop.yml +rails-diff dotfiles +``` + +### Global Options #### --fail-on-diff @@ -96,6 +103,14 @@ Skip specific files or directories during the diff. rails-diff generated scaffold Post --skip app/views app/helpers ``` +#### --only + +Only include specific files or directories in the diff. + +```bash +rails-diff generated scaffold Post --only app/models app/controllers +``` + #### --clear-cache/--no-cache Clear the cache directory to force cloning Rails and regenerating the Rails template app. diff --git a/lib/rails/diff.rb b/lib/rails/diff.rb index 03d33f9..14fbc25 100644 --- a/lib/rails/diff.rb +++ b/lib/rails/diff.rb @@ -1,130 +1,64 @@ # frozen_string_literal: true -require_relative "diff/version" -require_relative "diff/file_tracker" require "rails" -require "thor" require "diffy" require "fileutils" require "open3" +require_relative "diff/cli" +require_relative "diff/file_tracker" +require_relative "diff/logger" +require_relative "diff/rails_app_generator" +require_relative "diff/rails_repo" +require_relative "diff/version" module Rails module Diff - class Error < StandardError; end - - RAILS_REPO = "https://github.com/rails/rails.git" CACHE_DIR = File.expand_path("#{ENV["HOME"]}/.rails-diff/cache") - RAILSRC_PATH = "#{ENV["HOME"]}/.railsrc" class << self def file(*files, no_cache: false, commit: nil, new_app_options: nil) - clear_cache if no_cache - ensure_template_app_exists(commit, new_app_options) + app_generator = RailsAppGenerator.new(commit:, new_app_options:, no_cache:) + app_generator.create_template_app - files.filter_map { |it| diff_with_header(it) }.join("\n") + files + .filter_map { |it| diff_with_header(it, app_generator.template_app_path) } + .join("\n") end def generated(generator_name, *args, no_cache: false, skip: [], only: [], commit: nil, new_app_options: nil) - clear_cache if no_cache - ensure_template_app_exists(commit, new_app_options) - install_app_dependencies + app_generator = RailsAppGenerator.new(commit:, new_app_options:, no_cache:) + app_generator.create_template_app + app_generator.install_app_dependencies - generated_files(generator_name, *args, skip, only) - .map { |it| diff_with_header(it) } + app_generator.run_generator(generator_name, *args, skip, only) + .map { |it| diff_with_header(it, app_generator.template_app_path) } .join("\n\n") end - private - - def system!(*cmd, abort: true) + def system!(*cmd, logger:, abort: true) _, stderr, status = Open3.capture3(*cmd) - - debug cmd.join(" ") - + logger.debug(cmd.join(" ")) if status.success? true elsif abort - $stderr.puts "\e[1;31mCommand failed:\e[0m #{cmd.join(' ')}" + logger.error("Command failed:", cmd.join(" ")) abort stderr else false end end - def info(message) - puts "\e[1;34minfo:\e[0m\t#{message}" - end - - def debug(message) - return unless ENV["DEBUG"] - - puts "\e[1;33mdebug:\e[0m\t#{message}" - end - - def clear_cache - info "Clearing cache" - FileUtils.rm_rf(CACHE_DIR) - end - - def ensure_template_app_exists(commit, new_app_options) - FileUtils.mkdir_p(CACHE_DIR) - @new_app_options = new_app_options - @commit = commit || latest_commit - return if cached_app? - - create_new_rails_app - end - - def template_app_path - @template_app_path ||= File.join(CACHE_DIR, "rails-#{commit.first(10)}", rails_new_options_hash, app_name) - end - - def rails_path - @rails_path ||= begin - File.join(CACHE_DIR, "rails").tap do |path| - unless File.exist?(path) - info "Cloning Rails repository" - system!("git", "clone", "--depth", "1", RAILS_REPO, path) - end - end - end - end - - def railsrc_options - return @railsrc_options if defined?(@railsrc_options) - - @railsrc_options = File.read(RAILSRC_PATH).lines if File.exist?(RAILSRC_PATH) - end - - def app_name = @app_name ||= File.basename(Dir.pwd) - - def generated_files(generator_name, *args, skip, only) - Dir.chdir(template_app_path) do - system!("bin/rails", "destroy", generator_name, *args) - info "Running generator: rails generate #{generator_name} #{args.join(' ')}" - FileTracker.new.track_new_files(template_app_path, skip, only) { system!("bin/rails", "generate", generator_name, *args) } - .map { |it| it.delete_prefix("#{template_app_path}/") } - end - end + private - def diff_with_header(file) - diff = diff_file(file) + def diff_with_header(file, template_app_path) + diff = diff_file(file, template_app_path) return if diff.empty? header = "#{file} diff:" [header, "=" * header.size, diff].join("\n") end - def install_app_dependencies - Dir.chdir(template_app_path) do - unless system!("bundle check", abort: false) - info "Installing application dependencies" - system!("bundle install") - end - end - end - - def diff_file(file) + def diff_file(file, template_app_path) rails_file = File.join(template_app_path, file) repo_file = File.join(Dir.pwd, file) @@ -135,129 +69,9 @@ def diff_file(file) rails_file, repo_file, context: 2, - source: 'files' + source: "files" ).to_s(:color).chomp end - - def cached_app? - File.exist?(template_app_path) && !out_of_date_rails? - end - - def out_of_date_rails? - return true unless File.exist?(rails_path) - - Dir.chdir(rails_path) do - system!("git fetch origin main") - current = `git rev-parse HEAD`.strip - latest = `git rev-parse origin/main`.strip - - if current != latest - FileUtils.rm_rf(rails_path) - return true - end - end - - false - end - - def create_new_rails_app - Dir.chdir(rails_path) do - checkout_rails - generate_app - end - end - - def generate_app - Dir.chdir("railties") do - unless system!("bundle check", abort: false) - info "Installing Rails dependencies" - system!("bundle install") - end - - if railsrc_options - info "Using default options from #{RAILSRC_PATH}:\n\t > #{railsrc_options.join(' ')}" - end - - info "Generating new Rails application\n\t > #{rails_new_command.join(' ')}" - system!(*rails_new_command) - end - end - - def checkout_rails - info "Checking out Rails (at commit #{commit[0..6]})" - system!("git", "checkout", commit) - end - - def commit = @commit - - def new_app_options = @new_app_options - - def latest_commit - Dir.chdir(rails_path) do - `git rev-parse origin/main`.strip - end - end - - def rails_new_command = @rails_new_command ||= [ - "bundle", - "exec", - "rails", - "new", - template_app_path, - "--main", - "--skip-bundle", - "--force", - "--quiet", - *rails_new_options - ] - - def rails_new_options = @rails_new_options ||= [*new_app_options, *railsrc_options].compact - - def rails_new_options_hash = Digest::MD5.hexdigest(rails_new_options.join(" ")) - end - - class CLI < Thor - class_option :no_cache, type: :boolean, desc: "Clear cache before running", aliases: ["--clear-cache"] - class_option :fail_on_diff, type: :boolean, desc: "Fail if there are differences" - class_option :commit, type: :string, desc: "Compare against a specific commit" - class_option :new_app_options, type: :string, desc: "Options to pass to the rails new command" - class_option :debug, type: :boolean, desc: "Print debug information", aliases: ["-d"] - - def self.exit_on_failure? = true - - desc "file FILE [FILE ...]", "Compare one or more files from your repository with Rails' generated version" - def file(*files) - abort "Please provide at least one file to compare" if files.empty? - ENV["DEBUG"] = "true" if options[:debug] - - diff = Rails::Diff.file(*files, no_cache: options[:no_cache], commit: options[:commit], new_app_options: options[:new_app_options]) - return if diff.empty? - - options[:fail] ? abort(diff) : puts(diff) - end - - desc "generated GENERATOR [args]", "Compare files that would be created by a Rails generator" - option :skip, type: :array, desc: "Skip specific files or directories", aliases: ["-s"], default: [] - option :only, type: :array, desc: "Only include specific files or directories", aliases: ["-o"], default: [] - def generated(generator_name, *args) - ENV["DEBUG"] = "true" if options[:debug] - diff = Rails::Diff.generated(generator_name, - *args, - no_cache: options[:no_cache], - skip: options[:skip], - only: options[:only], - commit: options[:commit], - new_app_options: options[:new_app_options]) - return if diff.empty? - - options[:fail] ? abort(diff) : puts(diff) - end - - map %w[--version -v] => :__version - desc "--version, -v", "print the version" - def __version - puts VERSION - end end end end diff --git a/lib/rails/diff/cli.rb b/lib/rails/diff/cli.rb new file mode 100644 index 0000000..15dad58 --- /dev/null +++ b/lib/rails/diff/cli.rb @@ -0,0 +1,63 @@ +require "thor" + +module Rails + module Diff + class CLI < Thor + class_option :no_cache, type: :boolean, desc: "Clear cache before running", aliases: ["--clear-cache"] + class_option :fail_on_diff, type: :boolean, desc: "Fail if there are differences" + class_option :commit, type: :string, desc: "Compare against a specific commit" + class_option :new_app_options, type: :string, desc: "Options to pass to the rails new command" + class_option :debug, type: :boolean, desc: "Print debug information", aliases: ["-d"] + + def self.exit_on_failure? = true + + desc "file FILE [FILE ...]", "Compare one or more files from your repository with Rails' generated version" + def file(*files) + abort "Please provide at least one file to compare" if files.empty? + ENV["DEBUG"] = "true" if options[:debug] + + diff = Rails::Diff.file( + *files, + no_cache: options[:no_cache], + commit: options[:commit], + new_app_options: options[:new_app_options] + ) + return if diff.empty? + + options[:fail_on_diff] ? abort(diff) : puts(diff) + end + + desc "dotfiles", "Compare dotfiles in your repository with the ones generated by Rails" + def dotfiles + dotfiles = `git ls-files --cached --others --exclude-standard -- '.*'`.split("\n") + + file(*dotfiles) + end + + desc "generated GENERATOR [args]", "Compare files that would be created by a Rails generator" + option :skip, type: :array, desc: "Skip specific files or directories", aliases: ["-s"], default: [] + option :only, type: :array, desc: "Only include specific files or directories", aliases: ["-o"], default: [] + def generated(generator_name, *args) + ENV["DEBUG"] = "true" if options[:debug] + diff = Rails::Diff.generated( + generator_name, + *args, + no_cache: options[:no_cache], + skip: options[:skip], + only: options[:only], + commit: options[:commit], + new_app_options: options[:new_app_options] + ) + return if diff.empty? + + options[:fail_on_diff] ? abort(diff) : puts(diff) + end + + map %w[--version -v] => :__version + desc "--version, -v", "print the version" + def __version + puts VERSION + end + end + end +end diff --git a/lib/rails/diff/file_tracker.rb b/lib/rails/diff/file_tracker.rb index 8468164..cd822f8 100644 --- a/lib/rails/diff/file_tracker.rb +++ b/lib/rails/diff/file_tracker.rb @@ -1,29 +1,39 @@ # frozen_string_literal: true -class FileTracker - def track_new_files(template_app_path, skip, only = []) - files_before = list_files(template_app_path) +module Rails + module Diff + class FileTracker + def initialize(base_dir, skip = [], only = []) + @base_dir = base_dir + @skip = skip + @only = only + end + + def new_files + files_before = list_files(@base_dir) yield - files_after = list_files(template_app_path, skip, only) + files_after = list_files(@base_dir, @skip, @only) files_after - files_before end private - def list_files(dir, skip = [], only = []) - files = Dir.glob("#{dir}/**/*", File::FNM_DOTMATCH).reject do |it| - File.directory?(it) || - it.start_with?("#{dir}/.git") || - it.start_with?("#{dir}/tmp") || - it.start_with?("#{dir}/log") || - it.start_with?("#{dir}/test") || - skip.any? { |s| it.start_with?("#{dir}/#{s}") } - end + def list_files(dir, skip = [], only = []) + files = Dir.glob("#{dir}/**/*", File::FNM_DOTMATCH).reject do |it| + File.directory?(it) || + it.start_with?("#{dir}/.git") || + it.start_with?("#{dir}/tmp") || + it.start_with?("#{dir}/log") || + it.start_with?("#{dir}/test") || + skip.any? { |s| it.start_with?("#{dir}/#{s}") } + end - if only.any? - files.select { |it| only.any? { |o| it.start_with?("#{dir}/#{o}") } } - else - files + if only.any? + files.select { |it| only.any? { |o| it.start_with?("#{dir}/#{o}") } } + else + files + end end end - end \ No newline at end of file + end +end diff --git a/lib/rails/diff/logger.rb b/lib/rails/diff/logger.rb new file mode 100644 index 0000000..f2e3f54 --- /dev/null +++ b/lib/rails/diff/logger.rb @@ -0,0 +1,21 @@ +module Rails + module Diff + module Logger + extend self + + def info(message) + puts "\e[1;34minfo:\e[0m\t#{message}" + end + + def debug(message) + return unless ENV["DEBUG"] + + puts "\e[1;33mdebug:\e[0m\t#{message}" + end + + def error(label, message) + warn "\e[1;31m#{label}\e[0m #{message}" + end + end + end +end diff --git a/lib/rails/diff/rails_app_generator.rb b/lib/rails/diff/rails_app_generator.rb new file mode 100644 index 0000000..a2ad3cf --- /dev/null +++ b/lib/rails/diff/rails_app_generator.rb @@ -0,0 +1,95 @@ +require "digest" + +module Rails + module Diff + class RailsAppGenerator + RAILSRC_PATH = "#{ENV["HOME"]}/.railsrc" + + def initialize(commit: nil, new_app_options: nil, no_cache: false, logger: Logger, cache_dir: Rails::Diff::CACHE_DIR) + @new_app_options = new_app_options.to_s.split + @rails_repo = RailsRepo.new(logger:, cache_dir:) + @commit = commit + @logger = logger + @cache_dir = cache_dir + clear_cache if no_cache + end + + def clear_cache + logger.info "Clearing cache" + FileUtils.rm_rf(cache_dir, secure: true) + FileUtils.mkdir_p(cache_dir) + end + + def create_template_app + return if cached_app? + + create_new_rails_app + end + + def template_app_path + @template_app_path ||= File.join(cache_dir, rails_cache_dir_key, rails_new_options_hash, app_name) + end + + def install_app_dependencies + Dir.chdir(template_app_path) do + unless Rails::Diff.system!("bundle check", abort: false, logger: logger) + logger.info "Installing application dependencies" + Rails::Diff.system!("bundle install", logger: logger) + end + end + end + + def run_generator(generator_name, *args, skip, only) + Dir.chdir(template_app_path) do + Rails::Diff.system!("bin/rails", "destroy", generator_name, *args, logger: logger) + logger.info "Running generator: rails generate #{generator_name} #{args.join(" ")}" + + FileTracker.new(template_app_path, skip, only) + .new_files { Rails::Diff.system!("bin/rails", "generate", generator_name, *args, logger: logger) } + .map { |it| it.delete_prefix("#{template_app_path}/") } + end + end + + private + + attr_reader :new_app_options, :rails_repo, :logger, :cache_dir + + def commit = @commit ||= rails_repo.latest_commit + + def rails_cache_dir_key = "rails-#{commit.first(10)}" + + def railsrc_options + @railsrc_options ||= File.exist?(RAILSRC_PATH) ? File.readlines(RAILSRC_PATH) : [] + end + + def app_name = @app_name ||= File.basename(Dir.pwd) + + def cached_app? + File.exist?(template_app_path) && rails_repo.up_to_date? + end + + def create_new_rails_app + checkout_rails_commit + generate_app + end + + def generate_app + rails_repo.install_dependencies + if railsrc_options.any? + logger.info "Using default options from #{RAILSRC_PATH}:\n\t > #{railsrc_options.join(" ")}" + end + rails_repo.new_app(template_app_path, rails_new_options) + end + + def checkout_rails_commit = rails_repo.checkout(commit) + + def rails_new_options + @rails_new_options ||= (new_app_options + railsrc_options).compact + end + + def rails_new_options_hash + Digest::MD5.hexdigest(rails_new_options.join(" ")) + end + end + end +end diff --git a/lib/rails/diff/rails_repo.rb b/lib/rails/diff/rails_repo.rb new file mode 100644 index 0000000..8b9a480 --- /dev/null +++ b/lib/rails/diff/rails_repo.rb @@ -0,0 +1,88 @@ +module Rails + module Diff + class RailsRepo + RAILS_REPO = "https://github.com/rails/rails.git" + + def initialize(logger:, cache_dir: Rails::Diff::CACHE_DIR, rails_repo: RAILS_REPO) + @logger = logger + @cache_dir = cache_dir + @rails_repo = rails_repo + end + + def checkout(commit) + on_rails_dir do + logger.info "Checking out Rails (at commit #{commit[0..6]})" + Rails::Diff.system!("git", "checkout", commit, logger: logger) + end + end + + def latest_commit + @latest_commit ||= on_rails_dir do + Rails::Diff.system!("git fetch origin main", logger: logger) + `git rev-parse origin/main`.strip + end + end + + def up_to_date? + File.exist?(rails_path) && on_latest_commit? + end + + def install_dependencies + within "railties" do + unless Rails::Diff.system!("bundle check", abort: false, logger: logger) + logger.info "Installing Rails dependencies" + Rails::Diff.system!("bundle install", logger: logger) + end + end + end + + def new_app(name, options) + within "railties" do + command = rails_new_command(name, options) + logger.info "Generating new Rails application\n\t > #{command.join(" ")}" + Rails::Diff.system!(*command, logger: logger) + end + end + + def within(dir, &block) = on_rails_dir { Dir.chdir(dir, &block) } + + private + + attr_reader :logger, :cache_dir, :rails_repo + + def rails_path + File.join(cache_dir, "rails") + end + + def on_latest_commit? + if current_commit == latest_commit + true + else + remove_repo + false + end + end + + def on_rails_dir(&block) + clone_repo unless File.exist?(rails_path) + Dir.chdir(rails_path, &block) + end + + def current_commit = on_rails_dir { `git rev-parse HEAD`.strip } + + def remove_repo = FileUtils.rm_rf(rails_path, secure: true) + + def clone_repo + logger.info "Cloning Rails repository" + Rails::Diff.system!("git", "clone", "--depth", "1", rails_repo, rails_path, logger: logger) + end + + def rails_new_command(name, options) + [ + "bundle", "exec", "rails", "new", name, + "--main", "--skip-bundle", "--force", "--quiet", *options + ] + end + end + end +end diff --git a/lib/rails/diff/version.rb b/lib/rails/diff/version.rb index 2e6fa4a..4da0b83 100644 --- a/lib/rails/diff/version.rb +++ b/lib/rails/diff/version.rb @@ -2,6 +2,6 @@ module Rails module Diff - VERSION = "0.5.0" + VERSION = "0.6.0" end end diff --git a/spec/integration/rails/diff/file_tracker_spec.rb b/spec/integration/rails/diff/file_tracker_spec.rb index e513e46..8e12db8 100644 --- a/spec/integration/rails/diff/file_tracker_spec.rb +++ b/spec/integration/rails/diff/file_tracker_spec.rb @@ -1,77 +1,75 @@ # frozen_string_literal: true -require 'rails/diff/file_tracker' +require "rails/diff/file_tracker" -RSpec.describe FileTracker do - context 'integration tests' do - let(:temp_dir) { Dir.mktmpdir } +RSpec.describe Rails::Diff::FileTracker do + let(:temp_dir) { Dir.mktmpdir } - after do - FileUtils.remove_entry(temp_dir) - end - - it 'tracks newly created files' do - FileUtils.touch("#{temp_dir}/file1.rb") - file_tracker = FileTracker.new + after do + FileUtils.remove_entry(temp_dir) + end - new_files = file_tracker.track_new_files(temp_dir, []) do - FileUtils.touch("#{temp_dir}/file2.rb") - FileUtils.touch("#{temp_dir}/file3.rb") - end + it "tracks newly created files" do + FileUtils.touch("#{temp_dir}/file1.rb") + file_tracker = described_class.new(temp_dir) - expect(new_files).to contain_exactly("#{temp_dir}/file2.rb", "#{temp_dir}/file3.rb") + new_files = file_tracker.new_files do + FileUtils.touch("#{temp_dir}/file2.rb") + FileUtils.touch("#{temp_dir}/file3.rb") end - it 'excludes skipped files' do - FileUtils.touch("#{temp_dir}/file1.rb") - file_tracker = FileTracker.new + expect(new_files).to contain_exactly("#{temp_dir}/file2.rb", "#{temp_dir}/file3.rb") + end - new_files = file_tracker.track_new_files(temp_dir, ['file2.rb']) do - FileUtils.touch("#{temp_dir}/file2.rb") - FileUtils.touch("#{temp_dir}/file3.rb") - end + it "excludes skipped files" do + FileUtils.touch("#{temp_dir}/file1.rb") + file_tracker = described_class.new(temp_dir, ["file2.rb"]) - expect(new_files).to contain_exactly("#{temp_dir}/file3.rb") + new_files = file_tracker.new_files do + FileUtils.touch("#{temp_dir}/file2.rb") + FileUtils.touch("#{temp_dir}/file3.rb") end - it 'handles files with --only option' do - FileUtils.touch("#{temp_dir}/file1.rb") - file_tracker = FileTracker.new - new_files = file_tracker.track_new_files(temp_dir, [], ['file2.rb']) do - FileUtils.touch("#{temp_dir}/file2.rb") - FileUtils.touch("#{temp_dir}/file3.rb") - end - expect(new_files).to contain_exactly("#{temp_dir}/file2.rb") + expect(new_files).to contain_exactly("#{temp_dir}/file3.rb") + end + + it "handles files with only option" do + FileUtils.touch("#{temp_dir}/file1.rb") + file_tracker = described_class.new(temp_dir, [], ["file2.rb"]) + new_files = file_tracker.new_files do + FileUtils.touch("#{temp_dir}/file2.rb") + FileUtils.touch("#{temp_dir}/file3.rb") end + expect(new_files).to contain_exactly("#{temp_dir}/file2.rb") + end - it 'ignores files in special directories' do - FileUtils.mkdir_p("#{temp_dir}/.git") - FileUtils.mkdir_p("#{temp_dir}/tmp") - FileUtils.mkdir_p("#{temp_dir}/log") - FileUtils.touch("#{temp_dir}/file1.rb") - file_tracker = FileTracker.new - - new_files = file_tracker.track_new_files(temp_dir, []) do - FileUtils.touch("#{temp_dir}/.git/config") - FileUtils.touch("#{temp_dir}/tmp/cache") - FileUtils.touch("#{temp_dir}/log/development.log") - FileUtils.touch("#{temp_dir}/file2.rb") - end - - expect(new_files).to contain_exactly("#{temp_dir}/file2.rb") + it "ignores files in special directories" do + FileUtils.mkdir_p("#{temp_dir}/.git") + FileUtils.mkdir_p("#{temp_dir}/tmp") + FileUtils.mkdir_p("#{temp_dir}/log") + FileUtils.touch("#{temp_dir}/file1.rb") + file_tracker = described_class.new(temp_dir) + + new_files = file_tracker.new_files do + FileUtils.touch("#{temp_dir}/.git/config") + FileUtils.touch("#{temp_dir}/tmp/cache") + FileUtils.touch("#{temp_dir}/log/development.log") + FileUtils.touch("#{temp_dir}/file2.rb") end - it 'handles nested directories' do - FileUtils.touch("#{temp_dir}/file1.rb") - file_tracker = FileTracker.new + expect(new_files).to contain_exactly("#{temp_dir}/file2.rb") + end - new_files = file_tracker.track_new_files(temp_dir, []) do - FileUtils.mkdir_p("#{temp_dir}/nested/dir") - FileUtils.touch("#{temp_dir}/nested/file2.rb") - FileUtils.touch("#{temp_dir}/nested/dir/file3.rb") - end + it "handles nested directories" do + FileUtils.touch("#{temp_dir}/file1.rb") + file_tracker = described_class.new(temp_dir) - expect(new_files).to contain_exactly("#{temp_dir}/nested/file2.rb", "#{temp_dir}/nested/dir/file3.rb") + new_files = file_tracker.new_files do + FileUtils.mkdir_p("#{temp_dir}/nested/dir") + FileUtils.touch("#{temp_dir}/nested/file2.rb") + FileUtils.touch("#{temp_dir}/nested/dir/file3.rb") end + + expect(new_files).to contain_exactly("#{temp_dir}/nested/file2.rb", "#{temp_dir}/nested/dir/file3.rb") end end diff --git a/spec/lib/rails/diff/cli_spec.rb b/spec/lib/rails/diff/cli_spec.rb new file mode 100644 index 0000000..c793c9a --- /dev/null +++ b/spec/lib/rails/diff/cli_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# require "spec_helper" + +RSpec.describe Rails::Diff::CLI do + describe "#file" do + context "when --fail-on-diff is not specified" do + it "exits successfully" do + allow(Rails::Diff).to receive(:file).with("some_file.rb", kind_of(Hash)).and_return("diff output") + + expect { + described_class.start(["file", "some_file.rb"]) + }.not_to raise_error + end + end + + context "when --fail-on-diff is specified" do + it "exits with an error code" do + allow(Rails::Diff).to receive(:file).with("some_file.rb", kind_of(Hash)).and_return("diff output") + + expect { + described_class.start(["file", "some_file.rb", "--fail-on-diff"]) + }.to raise_error(SystemExit) + end + end + end +end diff --git a/spec/lib/rails/diff/rails_repo_spec.rb b/spec/lib/rails/diff/rails_repo_spec.rb new file mode 100644 index 0000000..2f91a95 --- /dev/null +++ b/spec/lib/rails/diff/rails_repo_spec.rb @@ -0,0 +1,146 @@ +# Helper indices for commits: +# @git_repo.commits[0] => 'add railties dir' (initial commit) +# @git_repo.commits[1] => 'commit1' +# @git_repo.commits.last => 'commit2' + +require "rails/diff/rails_repo" +require "fileutils" +require "tmpdir" + +RSpec.describe Rails::Diff::RailsRepo do + let(:logger) { spy } + let(:cache_dir) { Dir.mktmpdir } + let(:rails_path) { File.join(cache_dir, "rails") } + + before(:all) do + @git_repo = GitRepo.new.tap do |repo| + repo.add_commit("commit1") + repo.add_commit("commit2") + end + end + + after do + FileUtils.rm_rf(cache_dir) + @git_repo.cleanup + end + + describe "#up_to_date?" do + it "returns false when repo is not cloned yet" do + repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo) + + result = repo.up_to_date? + + expect(result).to eq false + end + + it "returns true when repo is on the latest commit" do + @git_repo.clone_at_commit(@git_repo.commits.last, rails_path) + repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo) + + result = repo.up_to_date? + + expect(result).to eq true + end + + it "returns false and removes the repo when repo is on an old commit" do + @git_repo.clone_at_commit(@git_repo.commits.first, rails_path) + repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo) + + result = repo.up_to_date? + + expect(result).to eq false + expect(File.exist?(rails_path)).to eq false + end + end + + describe "#latest_commit" do + it "returns the latest commit SHA from the remote repo" do + repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo) + + latest = repo.latest_commit + + expect(latest).to eq @git_repo.commits.last + end + + it "returns the new latest commit after a new commit is pushed" do + new_commit = @git_repo.add_commit("commit3") + repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo) + + latest = repo.latest_commit + + expect(latest).to eq new_commit + end + end + + describe "#checkout" do + it "checks out the given commit in the repo" do + @git_repo.clone_at_commit(@git_repo.commits.last, rails_path) + repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo) + + # Checkout the first file commit (not the initial railties commit) + repo.checkout(@git_repo.commits[1]) + + # Verify HEAD is now at the first file commit + current = Dir.chdir(rails_path) { `git rev-parse HEAD`.strip } + expect(current).to eq @git_repo.commits[1] + end + + it "logs the checkout info message" do + @git_repo.clone_at_commit(@git_repo.commits.last, rails_path) + repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo) + + repo.checkout(@git_repo.commits[1]) + + expect(logger).to have_received(:info) + .with(/Checking out Rails \(at commit #{@git_repo.commits[1][0..6]}\)/) + end + end + + describe "#install_dependencies" do + it "runs bundle check and bundle install if needed, and logs appropriately" do + @git_repo.clone_at_commit(@git_repo.commits[0], rails_path) + repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo) + + # Simulate bundle check failing, so bundle install is needed + allow(Rails::Diff).to receive(:system!).with("bundle check", abort: false, logger: logger).and_return(false) + allow(Rails::Diff).to receive(:system!).with("bundle install", logger: logger).and_return(true) + + repo.install_dependencies + + expect(logger).to have_received(:info).with("Installing Rails dependencies") + expect(Rails::Diff).to have_received(:system!).with("bundle install", logger: logger) + end + + it "does not run bundle install if bundle check passes" do + @git_repo.clone_at_commit(@git_repo.commits[0], rails_path) + repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo) + + # Simulate bundle check passing + allow(Rails::Diff).to receive(:system!).with("bundle check", abort: false, logger: logger).and_return(true) + + repo.install_dependencies + + expect(logger).not_to have_received(:info).with("Installing Rails dependencies") + end + end + + describe "#new_app" do + it "runs the rails new command with the correct arguments and logs the command" do + @git_repo.clone_at_commit(@git_repo.commits.last, rails_path) + repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo) + + allow(Rails::Diff).to receive(:system!).and_return(true) + app_name = "myapp" + options = ["--skip-test"] + + repo.new_app(app_name, options) + + expected_command = [ + "bundle", "exec", "rails", "new", app_name, + "--main", "--skip-bundle", "--force", "--quiet", *options + ] + expect(Rails::Diff).to have_received(:system!).with(*expected_command, logger: logger) + expect(logger).to have_received(:info).with(/Generating new Rails application/) + end + end +end diff --git a/spec/rails/diff_spec.rb b/spec/rails/diff_spec.rb deleted file mode 100644 index 24b4d43..0000000 --- a/spec/rails/diff_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Rails::Diff do - it "has a version number" do - expect(Rails::Diff::VERSION).not_to be nil - end - - describe ".generated" do - before do - allow(Rails::Diff).to receive(:system!).and_return(true) - allow(Rails::Diff).to receive(:ensure_template_app_exists) - allow(Rails::Diff).to receive(:install_app_dependencies) - allow(Rails::Diff).to receive(:generated_files).and_return(["file1.rb", "file2.rb"]) - allow(Rails::Diff).to receive(:diff_with_header).and_return("file1.rb diff:\n===\nDiff content") - end - - it "returns the diff for generated files" do - result = Rails::Diff.generated("model", "User", no_cache: true) - expect(result).to include("file1.rb diff:") - end - end - - describe Rails::Diff::CLI do - describe "#generated" do - before do - allow(Rails::Diff).to receive(:system!).and_return(true) - allow(Rails::Diff).to receive(:generated).and_return("file1.rb diff:\n===\nDiff content") - end - - it "runs without error" do - cli = Rails::Diff::CLI.new - expect { cli.generated("model", "User") }.not_to raise_error - end - end - end -end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 70fce95..9691d91 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,7 +1,13 @@ # frozen_string_literal: true +require "simplecov" +SimpleCov.start do + enable_coverage :branch + add_filter "/spec/" +end + require "rails/diff" -$LOAD_PATH.unshift(File.expand_path('../../lib', __dir__)) +Dir["./spec/support/**/*.rb"].sort.each { |f| require f } RSpec.configure do |config| # Enable flags like --only-failures and --next-failure diff --git a/spec/support/git_repo.rb b/spec/support/git_repo.rb new file mode 100644 index 0000000..89770e1 --- /dev/null +++ b/spec/support/git_repo.rb @@ -0,0 +1,58 @@ +require "fileutils" +require "tmpdir" + +class GitRepo + attr_reader :remote_repo, :commits + + # Initializes and creates a bare remote repo and a working repo with main branch + def initialize + @remote_dir = Dir.mktmpdir + @remote_repo = File.join(@remote_dir, "origin.git") + Dir.chdir(@remote_dir) { `git init --bare --initial-branch=main origin.git > /dev/null 2>&1` } + + @commits = [] + @work_dir = Dir.mktmpdir + Dir.chdir(@work_dir) do + `git clone #{@remote_repo} . > /dev/null 2>&1` + `git checkout -b main > /dev/null 2>&1` + FileUtils.mkdir_p("railties") + File.write("railties/README", "keep") + `git add railties > /dev/null 2>&1` + + `git config user.email 'test@example.com'` + `git config user.name 'Test User'` + + commit_result = `git commit -m "add railties dir" 2>&1` + raise "Initial commit failed: #{commit_result}" unless $?.success? + @commits << `git rev-parse HEAD`.strip + `git push -u origin main > /dev/null 2>&1` + end + end + + def add_commit(message) + Dir.chdir(@work_dir) do + `git commit --allow-empty -n -m "#{message}" > /dev/null 2>&1` + sha = `git rev-parse HEAD`.strip + @commits << sha + `git push origin main > /dev/null 2>&1` + `git fetch origin main > /dev/null 2>&1` + end + + @commits.last + end + + def clone_at_commit(commit_sha, dest_path) + `git clone #{@remote_repo} #{dest_path} > /dev/null 2>&1` + Dir.chdir(dest_path) do + `git checkout #{commit_sha} > /dev/null 2>&1` + `git fetch origin main > /dev/null 2>&1` + end + end + + def cleanup + Dir.chdir(@work_dir) do + `git reset --hard main > /dev/null 2>&1` + `git clean -fdx > /dev/null 2>&1` + end + end +end