Repository: MatheusRich/rails-diff Branch: main Commit: bbf7ce91f0cc Files: 28 Total size: 49.9 KB Directory structure: gitextract_achca_8k/ ├── .github/ │ └── workflows/ │ └── main.yml ├── .gitignore ├── .rspec ├── .ruby-version ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin/ │ ├── console │ └── setup ├── exe/ │ └── rails-diff ├── lib/ │ └── rails/ │ ├── diff/ │ │ ├── cli.rb │ │ ├── file_tracker.rb │ │ ├── logger.rb │ │ ├── rails_app_generator.rb │ │ ├── rails_repo.rb │ │ ├── shell.rb │ │ └── version.rb │ └── diff.rb ├── rails-diff.gemspec └── spec/ ├── integration/ │ └── rails/ │ └── diff/ │ └── file_tracker_spec.rb ├── lib/ │ └── rails/ │ ├── diff/ │ │ ├── cli_spec.rb │ │ ├── rails_app_generator_spec.rb │ │ └── rails_repo_spec.rb │ └── diff_spec.rb ├── spec_helper.rb └── support/ └── git_repo.rb ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/main.yml ================================================ name: Ruby on: push: branches: - main pull_request: jobs: build: runs-on: ubuntu-latest name: Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }} strategy: matrix: ruby: [ '3.2', '3.3', '3.4' ] rails: [ '7.x', '8.x' ] steps: - uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - name: Set Rails version environment variable run: echo "RAILS_VERSION=${{ matrix.rails }}" >> $GITHUB_ENV - name: Run the default task run: bundle exec rake ================================================ FILE: .gitignore ================================================ /.bundle/ /.yardoc /_yardoc/ /coverage/ /doc/ /pkg/ /spec/reports/ /tmp/ # rspec failure tracking .rspec_status ================================================ FILE: .rspec ================================================ --format documentation --color --require spec_helper ================================================ FILE: .ruby-version ================================================ 3.4.2 ================================================ FILE: CHANGELOG.md ================================================ ## [Unreleased] ## [0.8.0] - 2026-03-26 - `--ref` now accepts `rails --version` output (e.g., `--ref "Rails 7.2.3"` is converted to `v7.2.3`). ## [0.7.0] - 2026-03-17 - Replace `diffy` with `difftastic` for better diff output with syntax highlighting. - Add `--ref` option to compare against a specific tag, branch, or commit SHA (`--commit` is kept as an alias). - [BUGFIX] `--commit` (now `--ref`) failed with shallow clones when the ref wasn't locally available. ## [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. - Add optional debug logs. ```sh rails-diff file Gemfile --debug ``` or ```sh DEBUG=1 rails-diff file Gemfile ``` ## [0.4.1] - 2025-03-05 - Bump `rack` and `uri` minor versions. ## [0.4.0] - 2025-03-05 - Respect `~/.railsrc` file when generating new rails apps (PR #4). Thanks [@marcoroth](https://github.com/marcoroth) 🎉 - Use array version of `system` to avoid command injection. - Update cache keys to be shorter. - Improve log messages. ## [0.3.0] - 2025-02-23 - Allow passing options to generate the new application ```sh rails-diff file Gemfile --new-app-options="--database=postgresql" ``` ## [0.2.1] - 2025-02-22 - Add missing version command - Consistent error messages - Ensure rails path exists and dependencies are installed ## [0.2.0] - 2025-02-21 - Allow comparing a specific commit ```sh rails-diff file Dockerfile --commit 3e7640 ``` - Allow failing the command when there are diffs ```sh rails-diff file Dockerfile --fail-on-diff ``` - Return no output when there's no diff M## [0.1.1] - 2025-02-21 - Fix generator differ ## [0.1.0] - 2025-02-21 - Initial release [0.8.0]: https://github.com/matheusrich/rails-diff/releases/tag/v0.8.0 [0.7.0]: https://github.com/matheusrich/rails-diff/releases/tag/v0.7.0 [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 [0.2.1]: https://github.com/matheusrich/rails-diff/releases/tag/v0.2.1 [0.2.0]: https://github.com/matheusrich/rails-diff/releases/tag/v0.2.0 [0.1.1]: https://github.com/matheusrich/rails-diff/releases/tag/v0.1.1 [0.1.0]: https://github.com/matheusrich/rails-diff/releases/tag/v0.1.0 ================================================ FILE: Gemfile ================================================ # frozen_string_literal: true 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" ================================================ FILE: LICENSE.txt ================================================ The MIT License (MIT) Copyright (c) 2025 Matheus Richard Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Rails::Diff Compare your Rails application files with the ones generated by Rails main branch. This helps you keep track of changes between your customized files and the latest Rails templates. ## Installation Add this line to your application's Gemfile: ```ruby gem 'rails-diff' ``` And then execute: ```bash $ bundle install ``` Or install it yourself as: ```bash $ gem install rails-diff ``` ## Usage ### Compare specific files ```bash # Compare a single file rails-diff file Dockerfile # Compare multiple files rails-diff file Dockerfile Gemfile # Force regenerate Rails app by clearing cache rails-diff file Dockerfile --clear-cache # Fail if there are differences (useful for CI) rails-diff file Dockerfile --fail-on-diff # Compare a specific ref (tag, branch, or commit SHA) rails-diff file Dockerfile --ref v8.0.0 ``` ### Compare generator files ```bash # Compare files that would be created by a generator rails-diff generated authentication # Compare files with generator arguments rails-diff generated scaffold Post title:string body:text # Force regenerate Rails app by clearing cache rails-diff generated scaffold Post --clear-cache # Skip specific files or directories during the diff rails-diff generated scaffold Post --skip app/views app/helpers # Fail if there are differences (useful for CI) rails-diff generated scaffold Post --fail-on-diff # Compare a specific ref (tag, branch, or commit SHA) rails-diff generated authentication --ref v8.0.0 ``` ### Compare dotfiles (configuration files) ```bash # Compare configuration files like .gitignore, .rspec, .rubocop.yml rails-diff dotfiles ``` ### Compare infrastructure files Compare all Rails-generated infrastructure files at once. This includes everything except `app/` and `lib/` (your application code): `bin/`, `config/`, `db/`, `public/`, `Dockerfile`, `Gemfile`, `Rakefile`, dotfiles, and more. ```bash # Compare all infrastructure files rails-diff infra # Compare only specific directories rails-diff infra --only bin config # Skip additional directories rails-diff infra --skip db # Fail if there are differences (useful for CI) rails-diff infra --fail-on-diff ``` ### Global Options These options can be used with any of the commands above. #### --fail-on-diff If this option is specified, the command will exit with a non-zero status code if there are any differences between your files and the generated ones. This can be particularly useful when using the gem in Continuous Integration (CI) environments. #### --ref Specify a tag, branch, or commit SHA to compare against. If not provided, the latest commit on main will be used by default. `--commit` is kept as an alias. You can also pass `rails --version` output directly: ```bash rails-diff file Dockerfile --ref "$(rails --version)" ``` > [!NOTE] > When using a commit SHA, the full 40-character SHA is required (short SHAs are not supported). #### --new-app-options Specify additional options to be used with the `rails new` command. This allows you to customize the generated Rails application, for example, by specifying a different database. Example: ```bash rails-diff file Dockerfile --new-app-options="--database=postgresql" ``` #### --skip Skip specific files or directories during the diff. ```bash 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. ```bash rails-diff file Dockerfile --clear-cache ``` #### --debug Print debug information. ```sh rails-diff file Gemfile --debug ``` or with an environment variable: ```sh DEBUG=1 rails-diff file Gemfile ``` ## How it works When you run the diff, it will: 1. Clone the latest Rails from main branch 1. Generate a new Rails app with the same name as yours 1. Show you a colored diff between your file and the generated one ### Cache The gem caches the generated Rails application to avoid regenerating it on every run. The cache is automatically invalidated when: - Rails has new commits on main - The cache directory doesn't exist (or is cleared with the `--clear-cache` option) - You use `--new-app-options` with different options - You change your `~/.railsrc` file - You use `--ref` with a different ref You can also force clear the cache by using the `--no-cache` option (or its alias `--clear-cache`) with any command. ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. To install this gem onto your local machine, run `bundle exec rake install`. ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/matheusrich/rails-diff. ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). ================================================ FILE: Rakefile ================================================ # frozen_string_literal: true require "bundler/gem_tasks" require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec) task default: :spec task build: :spec ================================================ FILE: bin/console ================================================ #!/usr/bin/env ruby # frozen_string_literal: true require "bundler/setup" require "rails/diff" # 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. require "irb" IRB.start(__FILE__) ================================================ FILE: bin/setup ================================================ #!/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 ================================================ FILE: exe/rails-diff ================================================ #!/usr/bin/env ruby $LOAD_PATH.unshift("#{__dir__}/../lib") require "rails/diff" Rails::Diff::CLI.start(ARGV) ================================================ FILE: lib/rails/diff/cli.rb ================================================ 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 :ref, type: :string, desc: "Compare against a specific ref (tag, branch, or commit SHA)", aliases: ["--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], ref: options[:ref], 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], ref: options[:ref], new_app_options: options[:new_app_options] ) return if diff.empty? options[:fail_on_diff] ? abort(diff) : puts(diff) end desc "infra", "Compare non-application files in your repository (everything except app/ and lib/) with the ones generated by Rails" option :skip, type: :array, desc: "Additional files or directories to skip", aliases: ["-s"], default: [] option :only, type: :array, desc: "Only include specific files or directories", aliases: ["-o"], default: [] def infra ENV["DEBUG"] = "true" if options[:debug] diff = Rails::Diff.infra( no_cache: options[:no_cache], skip: options[:skip], only: options[:only], ref: options[:ref], 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 ================================================ FILE: lib/rails/diff/file_tracker.rb ================================================ # frozen_string_literal: true module Rails module Diff module FileTracker DEFAULT_EXCLUSIONS = %w[.git tmp log test].freeze def self.new_files(base_dir, only:, skip: []) files_before = list_files(base_dir) yield files_after = list_files(base_dir, skip:, only:) files_after - files_before end def self.list_files(dir, skip: [], only: [], exclusions: DEFAULT_EXCLUSIONS) files = Dir.glob("#{dir}/**/*", File::FNM_DOTMATCH).reject do |it| File.directory?(it) || exclusions.any? { |e| it.start_with?("#{dir}/#{e}") } || 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 end end end end end ================================================ FILE: lib/rails/diff/logger.rb ================================================ 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 ================================================ FILE: lib/rails/diff/rails_app_generator.rb ================================================ require "digest" module Rails module Diff class RailsAppGenerator def initialize(ref: nil, new_app_options: nil, no_cache: false, logger: Logger, cache_dir: Rails::Diff::CACHE_DIR, rails_repo: RailsRepo.new(logger:, cache_dir:)) @new_app_options = new_app_options.to_s.split @rails_repo = rails_repo @ref = normalize_ref(ref) @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 Shell.run!("bundle check", abort: false, logger:) logger.info "Installing application dependencies" Shell.run!("bundle install", logger:) end end end def run_generator(generator_name, *args, skip, only) Dir.chdir(template_app_path) do Shell.run!("bin/rails", "destroy", generator_name, *args, logger:) logger.info "Running generator: rails generate #{generator_name} #{args.join(" ")}" FileTracker .new_files(template_app_path, skip:, only:) { Shell.run!("bin/rails", "generate", generator_name, *args, logger:) } .map { |it| it.delete_prefix("#{template_app_path}/") } end end private attr_reader :new_app_options, :rails_repo, :logger, :cache_dir def normalize_ref(ref) if ref&.start_with?("Rails ") "v#{ref.delete_prefix("Rails ")}" else ref end end def ref = @ref ||= rails_repo.latest_commit def rails_cache_dir_key = "rails-#{ref.first(10)}" def railsrc_path = "#{ENV["HOME"]}/.railsrc" 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_ref 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_ref = rails_repo.checkout(ref) 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 ================================================ FILE: lib/rails/diff/rails_repo.rb ================================================ 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(ref) on_rails_dir do logger.info "Checking out Rails (at #{ref})" unless Shell.run!("git", "checkout", ref, abort: false, logger:) Shell.run!("git", "fetch", "--depth", "1", "origin", ref, logger:) Shell.run!("git", "checkout", "FETCH_HEAD", logger:) end end end def latest_commit @latest_commit ||= on_rails_dir do Shell.run!("git fetch origin main", 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 Shell.run!("bundle check", abort: false, logger:) logger.info "Installing Rails dependencies" Shell.run!("bundle", "config", "set", "--local", "without", "db", logger:) Shell.run!("bundle", "install", 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(" ")}" Shell.run!(*command, 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" Shell.run!("git", "clone", "--depth", "1", rails_repo, rails_path, logger:) end def rails_new_command(name, options) [ "bundle", "exec", "rails", "new", name, "--main", "--skip-bundle", "--force", "--quiet", *options ] end end end end ================================================ FILE: lib/rails/diff/shell.rb ================================================ # frozen_string_literal: true require "open3" module Rails module Diff module Shell extend self def run!(*cmd, logger:, abort: true) _, stderr, status = Open3.capture3(*cmd) logger.debug(cmd.join(" ")) if status.success? true elsif abort logger.error("Command failed:", cmd.join(" ")) abort stderr else false end end end end end ================================================ FILE: lib/rails/diff/version.rb ================================================ # frozen_string_literal: true module Rails module Diff VERSION = "0.8.0" end end ================================================ FILE: lib/rails/diff.rb ================================================ # frozen_string_literal: true require "rails" require "difftastic" require "fileutils" require_relative "diff/cli" require_relative "diff/file_tracker" require_relative "diff/logger" require_relative "diff/shell" require_relative "diff/rails_app_generator" require_relative "diff/rails_repo" require_relative "diff/version" module Rails module Diff CACHE_DIR = File.expand_path("#{ENV["HOME"]}/.rails-diff/cache") class << self def file(*files, no_cache: false, ref: nil, new_app_options: nil, app_generator: RailsAppGenerator.new(ref:, new_app_options:, no_cache:), differ_class: Difftastic::Differ) app_generator.create_template_app files .filter_map { |it| diff_with_header(it, app_generator.template_app_path, differ_class:) } .join("\n") end def generated(generator_name, *args, no_cache: false, skip: [], only: [], ref: nil, new_app_options: nil, app_generator: RailsAppGenerator.new(ref:, new_app_options:, no_cache:), differ_class: Difftastic::Differ) app_generator.create_template_app app_generator.install_app_dependencies app_generator.run_generator(generator_name, *args, skip, only) .filter_map { |it| diff_with_header(it, app_generator.template_app_path, differ_class:) } .join("\n\n") end def infra(no_cache: false, skip: [], only: [], ref: nil, new_app_options: nil, app_generator: RailsAppGenerator.new(ref:, new_app_options:, no_cache:), differ_class: Difftastic::Differ) app_generator.create_template_app default_skip = %w[app lib] effective_skip = (default_skip + skip).uniq FileTracker.list_files(app_generator.template_app_path, skip: effective_skip, only:) .map { |f| f.delete_prefix("#{app_generator.template_app_path}/") } .filter_map { |it| diff_with_header(it, app_generator.template_app_path, differ_class:) } .join("\n\n") end private def diff_with_header(file, template_app_path, differ_class:) diff = diff_file(file, template_app_path, differ_class:) return if diff.empty? header = "#{file} diff:" [header, "=" * header.size, diff].join("\n") end def diff_file(file, template_app_path, differ_class:) rails_file = File.join(template_app_path, file) repo_file = File.join(Dir.pwd, file) return "File not found in the Rails template" unless File.exist?(rails_file) return "File not found in your repository" unless File.exist?(repo_file) differ = differ_class.new( color: :always, left_label: "Rails File (#{file})", right_label: "Repo File (#{file})" ) differ.diff_files(rails_file, repo_file).chomp end end end end ================================================ FILE: rails-diff.gemspec ================================================ # frozen_string_literal: true require_relative "lib/rails/diff/version" Gem::Specification.new do |spec| spec.name = "rails-diff" spec.version = Rails::Diff::VERSION spec.authors = ["Matheus Richard"] spec.email = ["matheusrichardt@gmail.com"] spec.summary = "Compare Rails-generated files with the ones in your repository" spec.description = "rails-diff helps you compare files generated by Rails (like Dockerfile, .gitignore, etc) with the ones in your repository, making it easier to keep track of changes and updates." spec.homepage = "https://github.com/matheusrich/rails-diff" spec.license = "MIT" spec.required_ruby_version = ">= 3.2" spec.metadata["allowed_push_host"] = "https://rubygems.org" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = "https://github.com/matheusrich/rails-diff" spec.metadata["changelog_uri"] = "https://github.com/matheusrich/rails-diff/blob/main/CHANGELOG.md" # 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. gemspec = File.basename(__FILE__) spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| ls.readlines("\x0", chomp: true).reject do |f| (f == gemspec) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) end end spec.bindir = "exe" spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] spec.add_dependency "rails", ">= 7.0" spec.add_dependency "difftastic", "~> 0.8" spec.add_dependency "thor", "~> 1.0" # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html end ================================================ FILE: spec/integration/rails/diff/file_tracker_spec.rb ================================================ # frozen_string_literal: true require "rails/diff/file_tracker" 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") new_files = described_class.new_files(temp_dir, only: []) do FileUtils.touch("#{temp_dir}/file2.rb") FileUtils.touch("#{temp_dir}/file3.rb") end expect(new_files).to contain_exactly("#{temp_dir}/file2.rb", "#{temp_dir}/file3.rb") end it "excludes skipped files" do FileUtils.touch("#{temp_dir}/file1.rb") new_files = described_class.new_files(temp_dir, skip: ["file2.rb"], only: []) do FileUtils.touch("#{temp_dir}/file2.rb") FileUtils.touch("#{temp_dir}/file3.rb") end expect(new_files).to contain_exactly("#{temp_dir}/file3.rb") end it "handles files with only option" do FileUtils.touch("#{temp_dir}/file1.rb") new_files = described_class.new_files(temp_dir, only: ["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") 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") new_files = described_class.new_files(temp_dir, only: []) 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") end it "handles nested directories" do FileUtils.touch("#{temp_dir}/file1.rb") new_files = described_class.new_files(temp_dir, only: []) 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 ================================================ FILE: spec/lib/rails/diff/cli_spec.rb ================================================ # 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 describe "#infra" do context "when --fail-on-diff is not specified" do it "exits successfully" do allow(Rails::Diff).to receive(:infra).with(kind_of(Hash)).and_return("diff output") expect { described_class.start(["infra"]) }.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(:infra).with(kind_of(Hash)).and_return("diff output") expect { described_class.start(["infra", "--fail-on-diff"]) }.to raise_error(SystemExit) end end it "passes skip and only options to Rails::Diff.infra" do expect(Rails::Diff).to receive(:infra).with(hash_including(skip: ["config"], only: ["bin"])).and_return("") described_class.start(["infra", "--skip", "config", "--only", "bin"]) end end end ================================================ FILE: spec/lib/rails/diff/rails_app_generator_spec.rb ================================================ # frozen_string_literal: true require "rails/diff/rails_app_generator" RSpec.describe Rails::Diff::RailsAppGenerator do let(:cache_dir) { Dir.mktmpdir } let(:work_dir) { Dir.mktmpdir("myapp") } around do |example| original_home = ENV["HOME"] ENV["HOME"] = work_dir Dir.chdir(work_dir) { example.run } ensure ENV["HOME"] = original_home FileUtils.rm_rf(cache_dir) FileUtils.rm_rf(work_dir) end def build_generator(ref: "abc1234567890def", logger: spy, rails_repo: spy(latest_commit: ref, up_to_date?: false), **options) described_class.new(ref:, logger:, cache_dir:, rails_repo:, **options) end def stub_command(*args, result: true, **opts) allow(Rails::Diff::Shell).to receive(:run!).with(*args, **opts).and_return(result) end describe "#create_template_app" do it "checks out the ref, installs dependencies, and generates a new app" do repo = spy(latest_commit: "abc1234567890def", up_to_date?: false) generator = build_generator(rails_repo: repo) generator.create_template_app expect(repo).to have_received(:checkout).with("abc1234567890def") expect(repo).to have_received(:install_dependencies) expect(repo).to have_received(:new_app).with(generator.template_app_path, []) end it "passes new_app_options to rails new" do repo = spy(latest_commit: "abc1234567890def", up_to_date?: false) generator = build_generator(rails_repo: repo, new_app_options: "--api --skip-test") generator.create_template_app expect(repo).to have_received(:new_app).with( generator.template_app_path, ["--api", "--skip-test"] ) end it "merges options from .railsrc when it exists" do File.write(File.join(work_dir, ".railsrc"), "--skip-test\n") repo = spy(latest_commit: "abc1234567890def", up_to_date?: false) logger = spy generator = build_generator(rails_repo: repo, logger:, new_app_options: "--api") generator.create_template_app expect(repo).to have_received(:new_app).with( generator.template_app_path, ["--api", "--skip-test\n"] ) expect(logger).to have_received(:info).with(/Using default options from/) end it "defaults ref to the latest commit from the rails repo" do repo = spy(latest_commit: "abc1234567890def", up_to_date?: false) generator = described_class.new(logger: spy, cache_dir:, rails_repo: repo) generator.create_template_app expect(repo).to have_received(:checkout).with("abc1234567890def") end it "skips creation when the app is cached and up to date" do repo = spy(latest_commit: "abc1234567890def", up_to_date?: true) generator = build_generator(rails_repo: repo) FileUtils.mkdir_p(generator.template_app_path) generator.create_template_app expect(repo).not_to have_received(:checkout) expect(repo).not_to have_received(:new_app) end it "recreates the app when the repo is outdated" do repo = spy(latest_commit: "abc1234567890def", up_to_date?: false) generator = build_generator(rails_repo: repo) FileUtils.mkdir_p(generator.template_app_path) generator.create_template_app expect(repo).to have_received(:checkout).with("abc1234567890def") expect(repo).to have_received(:new_app) end end describe "ref normalization" do it "converts 'Rails X.Y.Z' format to 'vX.Y.Z'" do repo = spy(latest_commit: "v7.2.3", up_to_date?: false) generator = build_generator(ref: "Rails 7.2.3", rails_repo: repo) generator.create_template_app expect(repo).to have_received(:checkout).with("v7.2.3") end it "passes through refs that don't start with 'Rails '" do repo = spy(latest_commit: "abc1234567890def", up_to_date?: false) generator = build_generator(ref: "v7.2.3", rails_repo: repo) generator.create_template_app expect(repo).to have_received(:checkout).with("v7.2.3") end end describe "#clear_cache" do it "removes and recreates the cache directory" do generator = build_generator marker = File.join(cache_dir, "some_cached_file") FileUtils.touch(marker) generator.clear_cache expect(File.exist?(marker)).to eq(false) expect(Dir.exist?(cache_dir)).to eq(true) end it "is called on initialization when no_cache is true" do repo = spy(latest_commit: "abc1234567890def", up_to_date?: false) marker = File.join(cache_dir, "some_cached_file") FileUtils.touch(marker) build_generator(rails_repo: repo, no_cache: true) expect(File.exist?(marker)).to eq(false) expect(Dir.exist?(cache_dir)).to eq(true) end end describe "#template_app_path" do it "includes the ref prefix in the path" do generator = build_generator expect(generator.template_app_path).to include("rails-abc1234567") end it "uses the current directory name as the app name" do generator = build_generator expect(generator.template_app_path).to end_with(File.basename(work_dir)) end it "generates different paths for different options" do gen1 = build_generator(new_app_options: "--api") gen2 = build_generator(new_app_options: "--skip-test") expect(gen1.template_app_path).not_to eq(gen2.template_app_path) end end describe "#install_app_dependencies" do it "runs bundle install when bundle check fails" do logger = spy generator = build_generator(logger:) FileUtils.mkdir_p(generator.template_app_path) stub_command("bundle check", abort: false, logger:, result: false) stub_command("bundle install", logger:) generator.install_app_dependencies expect(Rails::Diff::Shell).to have_received(:run!).with("bundle install", logger:) end it "skips bundle install when bundle check passes" do logger = spy generator = build_generator(logger:) FileUtils.mkdir_p(generator.template_app_path) stub_command("bundle check", abort: false, logger:, result: true) generator.install_app_dependencies expect(Rails::Diff::Shell).not_to have_received(:run!).with("bundle install", logger:) end end describe "#run_generator" do it "destroys existing files and regenerates them" do logger = spy generator = build_generator(logger:) FileUtils.mkdir_p(generator.template_app_path) stub_command("bin/rails", "destroy", "model", "User", logger:) stub_command("bin/rails", "generate", "model", "User", logger:) allow(Rails::Diff::FileTracker).to receive(:new_files) .and_yield .and_return(["#{generator.template_app_path}/app/models/user.rb"]) generator.run_generator("model", "User", [], []) expect(Rails::Diff::Shell).to have_received(:run!) .with("bin/rails", "destroy", "model", "User", logger:) expect(Rails::Diff::Shell).to have_received(:run!) .with("bin/rails", "generate", "model", "User", logger:) end it "returns relative file paths" do generator = build_generator FileUtils.mkdir_p(generator.template_app_path) stub_command("bin/rails", "destroy", "model", "User", logger: anything) stub_command("bin/rails", "generate", "model", "User", logger: anything) allow(Rails::Diff::FileTracker).to receive(:new_files) .and_return([ "#{generator.template_app_path}/app/models/user.rb", "#{generator.template_app_path}/db/migrate/001_create_users.rb" ]) result = generator.run_generator("model", "User", [], []) expect(result).to eq(["app/models/user.rb", "db/migrate/001_create_users.rb"]) end end end ================================================ FILE: spec/lib/rails/diff/rails_repo_spec.rb ================================================ # 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 "checks out a tag in a shallow clone" do tagged_commit = @git_repo.commits.last @git_repo.add_tag("v1.0.0") @git_repo.add_commit("after tag") @git_repo.shallow_clone(rails_path) repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo) repo.checkout("v1.0.0") current = Dir.chdir(rails_path) { `git rev-parse HEAD`.strip } expect(current).to eq tagged_commit 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 #{@git_repo.commits[1]}\)/) end end describe "#install_dependencies" do it "runs bundle check and bundle install excluding db group 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::Shell).to receive(:run!).with("bundle check", abort: false, logger:).and_return(false) allow(Rails::Diff::Shell).to receive(:run!).with("bundle", "config", "set", "--local", "without", "db", logger:).and_return(true) allow(Rails::Diff::Shell).to receive(:run!).with("bundle", "install", logger:).and_return(true) repo.install_dependencies expect(logger).to have_received(:info).with("Installing Rails dependencies") expect(Rails::Diff::Shell).to have_received(:run!).with("bundle", "config", "set", "--local", "without", "db", logger:) expect(Rails::Diff::Shell).to have_received(:run!).with("bundle", "install", 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::Shell).to receive(:run!).with("bundle check", abort: false, 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::Shell).to receive(:run!).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::Shell).to have_received(:run!).with(*expected_command, logger:) expect(logger).to have_received(:info).with(/Generating new Rails application/) end end end ================================================ FILE: spec/lib/rails/diff_spec.rb ================================================ # frozen_string_literal: true RSpec.describe Rails::Diff do let(:template_dir) { Dir.mktmpdir } let(:repo_dir) { Dir.mktmpdir("repo") } around do |example| Dir.chdir(repo_dir) { example.run } ensure FileUtils.rm_rf(template_dir) FileUtils.rm_rf(repo_dir) end def mock_generator(template_path: template_dir) spy(template_app_path: template_path) end def fake_differ_class(output: "some diff output") Class.new do define_method(:initialize) { |**| } define_method(:diff_files) { |*, **| "#{output}\n" } end end def write_file(dir, path, content) full_path = File.join(dir, path) FileUtils.mkdir_p(File.dirname(full_path)) File.write(full_path, content) end describe ".file" do it "returns diff output when files differ" do write_file(template_dir, "Gemfile", "gem 'rails'\n") write_file(repo_dir, "Gemfile", "gem 'rails'\ngem 'sidekiq'\n") result = described_class.file("Gemfile", app_generator: mock_generator, differ_class: fake_differ_class(output: "line changed")) expect(result).to include("Gemfile diff:") expect(result).to include("line changed") end it "reports when file is missing from the Rails template" do write_file(repo_dir, "custom.rb", "# custom file\n") result = described_class.file("custom.rb", app_generator: mock_generator, differ_class: fake_differ_class) expect(result).to include("File not found in the Rails template") end it "reports when file is missing from the repository" do write_file(template_dir, "Gemfile", "gem 'rails'\n") result = described_class.file("Gemfile", app_generator: mock_generator, differ_class: fake_differ_class) expect(result).to include("File not found in your repository") end it "returns empty string when files are identical" do write_file(template_dir, "Gemfile", "gem 'rails'\n") write_file(repo_dir, "Gemfile", "gem 'rails'\n") result = described_class.file("Gemfile", app_generator: mock_generator, differ_class: fake_differ_class(output: "")) expect(result).to eq("") end it "normalizes 'Rails X.Y.Z' ref format before checking out" do repo = spy(latest_commit: "v7.2.3", up_to_date?: false) generator = Rails::Diff::RailsAppGenerator.new( ref: "Rails 7.2.3", logger: spy, cache_dir: template_dir, rails_repo: repo ) write_file(template_dir, "Gemfile", "gem 'rails'\n") write_file(repo_dir, "Gemfile", "gem 'rails'\n") described_class.file("Gemfile", app_generator: generator, differ_class: fake_differ_class(output: "")) expect(repo).to have_received(:checkout).with("v7.2.3") end it "calls create_template_app on the generator" do generator = mock_generator write_file(template_dir, "Gemfile", "gem 'rails'\n") write_file(repo_dir, "Gemfile", "gem 'rails'\n") described_class.file("Gemfile", app_generator: generator, differ_class: fake_differ_class(output: "")) expect(generator).to have_received(:create_template_app) end end describe ".infra" do it "skips app/ and lib/ directories by default" do write_file(template_dir, "app/models/user.rb", "class User; end\n") write_file(template_dir, "lib/tasks.rb", "# tasks\n") write_file(template_dir, "config/routes.rb", "# routes\n") write_file(repo_dir, "config/routes.rb", "# different routes\n") result = described_class.infra(app_generator: mock_generator, differ_class: fake_differ_class(output: "routes changed")) expect(result).to include("config/routes.rb diff:") expect(result).not_to include("app/models") expect(result).not_to include("lib/tasks") end it "merges user skip options with defaults" do write_file(template_dir, "config/routes.rb", "# routes\n") write_file(template_dir, "bin/rails", "#!/usr/bin/env ruby\n") write_file(repo_dir, "config/routes.rb", "# different\n") write_file(repo_dir, "bin/rails", "# different\n") result = described_class.infra(skip: ["config"], app_generator: mock_generator, differ_class: fake_differ_class(output: "bin changed")) expect(result).not_to include("config/routes.rb") expect(result).to include("bin/rails diff:") end end describe ".generated" do it "installs dependencies and runs the generator" do generator = mock_generator allow(generator).to receive(:run_generator).and_return([]) described_class.generated("model", "User", app_generator: generator, differ_class: fake_differ_class) expect(generator).to have_received(:create_template_app) expect(generator).to have_received(:install_app_dependencies) expect(generator).to have_received(:run_generator).with("model", "User", [], []) end it "diffs generated files" do generator = mock_generator write_file(template_dir, "app/models/user.rb", "class User; end\n") write_file(repo_dir, "app/models/user.rb", "class User < ApplicationRecord; end\n") allow(generator).to receive(:run_generator).and_return(["app/models/user.rb"]) result = described_class.generated("model", "User", app_generator: generator, differ_class: fake_differ_class(output: "model changed")) expect(result).to include("app/models/user.rb diff:") end end end ================================================ FILE: spec/spec_helper.rb ================================================ # frozen_string_literal: true require "simplecov" SimpleCov.start do enable_coverage :branch add_filter "/spec/" end require "rails/diff" Dir["./spec/support/**/*.rb"].sort.each { |f| require f } RSpec.configure do |config| # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" # Disable RSpec exposing methods globally on `Module` and `main` config.disable_monkey_patching! config.expect_with :rspec do |c| c.syntax = :expect end end ================================================ FILE: spec/support/git_repo.rb ================================================ 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 add_tag(tag_name) Dir.chdir(@work_dir) do `git tag #{tag_name} > /dev/null 2>&1` `git push origin #{tag_name} > /dev/null 2>&1` end end def shallow_clone(dest_path) `git clone --depth 1 file://#{@remote_repo} #{dest_path} > /dev/null 2>&1` 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