[
  {
    "path": ".github/workflows/main.yml",
    "content": "name: Ruby\n\non:\n  push:\n    branches:\n      - main\n\n  pull_request:\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    name: Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }}\n    strategy:\n      matrix:\n        ruby: [ '3.2', '3.3', '3.4' ]\n        rails: [ '7.x', '8.x' ]\n    steps:\n      - uses: actions/checkout@v4\n      - name: Set up Ruby\n        uses: ruby/setup-ruby@v1\n        with:\n          ruby-version: ${{ matrix.ruby }}\n          bundler-cache: true\n      - name: Set Rails version environment variable\n        run: echo \"RAILS_VERSION=${{ matrix.rails }}\" >> $GITHUB_ENV\n      - name: Run the default task\n        run: bundle exec rake\n"
  },
  {
    "path": ".gitignore",
    "content": "/.bundle/\n/.yardoc\n/_yardoc/\n/coverage/\n/doc/\n/pkg/\n/spec/reports/\n/tmp/\n\n# rspec failure tracking\n.rspec_status\n"
  },
  {
    "path": ".rspec",
    "content": "--format documentation\n--color\n--require spec_helper\n"
  },
  {
    "path": ".ruby-version",
    "content": "3.4.2\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "## [Unreleased]\n\n## [0.8.0] - 2026-03-26\n\n- `--ref` now accepts `rails --version` output (e.g., `--ref \"Rails 7.2.3\"` is converted to `v7.2.3`).\n\n## [0.7.0] - 2026-03-17\n\n- Replace `diffy` with `difftastic` for better diff output with syntax highlighting.\n- Add `--ref` option to compare against a specific tag, branch, or commit SHA (`--commit` is kept as an alias).\n- [BUGFIX] `--commit` (now `--ref`) failed with shallow clones when the ref wasn't locally available.\n\n## [0.6.0] - 2025-07-25\n\n- Add `--only` option to only include specific files or directories in the diff.\n- Add `rails-diff dotfiles` to compare dotfiles (configuration files) in the repository.\n- [BUGFIX] --fail-on-diff wasn't aborting with errors on diff.\n\n## [0.5.0] - 2025-03-10\n\n- Don't abort process on bundle check failure.\n- Add optional debug logs.\n\n```sh\nrails-diff file Gemfile --debug\n```\n\nor\n\n```sh\nDEBUG=1 rails-diff file Gemfile\n```\n\n## [0.4.1] - 2025-03-05\n\n- Bump `rack` and `uri` minor versions.\n\n## [0.4.0] - 2025-03-05\n\n- Respect `~/.railsrc` file when generating new rails apps (PR #4). Thanks [@marcoroth](https://github.com/marcoroth) 🎉\n- Use array version of `system` to avoid command injection.\n- Update cache keys to be shorter.\n- Improve log messages.\n\n## [0.3.0] - 2025-02-23\n\n- Allow passing options to generate the new application\n\n```sh\nrails-diff file Gemfile --new-app-options=\"--database=postgresql\"\n```\n\n## [0.2.1] - 2025-02-22\n\n- Add missing version command\n- Consistent error messages\n- Ensure rails path exists and dependencies are installed\n\n## [0.2.0] - 2025-02-21\n\n- Allow comparing a specific commit\n\n```sh\nrails-diff file Dockerfile --commit 3e7640\n```\n\n- Allow failing the command when there are diffs\n\n```sh\nrails-diff file Dockerfile --fail-on-diff\n```\n\n- Return no output when there's no diff\n\nM## [0.1.1] - 2025-02-21\n\n- Fix generator differ\n\n## [0.1.0] - 2025-02-21\n\n- Initial release\n\n[0.8.0]: https://github.com/matheusrich/rails-diff/releases/tag/v0.8.0\n[0.7.0]: https://github.com/matheusrich/rails-diff/releases/tag/v0.7.0\n[0.6.0]: https://github.com/matheusrich/rails-diff/releases/tag/v0.6.0\n[0.5.0]: https://github.com/matheusrich/rails-diff/releases/tag/v0.5.0\n[0.4.1]: https://github.com/matheusrich/rails-diff/releases/tag/v0.4.1\n[0.4.1]: https://github.com/matheusrich/rails-diff/releases/tag/v0.4.1\n[0.4.0]: https://github.com/matheusrich/rails-diff/releases/tag/v0.4.0\n[0.3.0]: https://github.com/matheusrich/rails-diff/releases/tag/v0.3.0\n[0.2.1]: https://github.com/matheusrich/rails-diff/releases/tag/v0.2.1\n[0.2.0]: https://github.com/matheusrich/rails-diff/releases/tag/v0.2.0\n[0.1.1]: https://github.com/matheusrich/rails-diff/releases/tag/v0.1.1\n[0.1.0]: https://github.com/matheusrich/rails-diff/releases/tag/v0.1.0\n"
  },
  {
    "path": "Gemfile",
    "content": "# frozen_string_literal: true\n\nsource \"https://rubygems.org\"\n\n# Specify your gem's dependencies in rails-diff.gemspec\ngemspec\n\ngem \"simplecov\", require: false\ngem \"standard\", \"~> 1.0\"\ngem \"rake\", \"~> 13.0\"\n\ngem \"rspec\", \"~> 3.0\"\n"
  },
  {
    "path": "LICENSE.txt",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2025 Matheus Richard\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Rails::Diff\n\nCompare your Rails application files with the ones generated by Rails main\nbranch. This helps you keep track of changes between your customized files and\nthe latest Rails templates.\n\n## Installation\n\nAdd this line to your application's Gemfile:\n\n```ruby\ngem 'rails-diff'\n```\n\nAnd then execute:\n\n```bash\n$ bundle install\n```\n\nOr install it yourself as:\n\n```bash\n$ gem install rails-diff\n```\n\n## Usage\n\n### Compare specific files\n\n```bash\n# Compare a single file\nrails-diff file Dockerfile\n\n# Compare multiple files\nrails-diff file Dockerfile Gemfile\n\n# Force regenerate Rails app by clearing cache\nrails-diff file Dockerfile --clear-cache\n\n# Fail if there are differences (useful for CI)\nrails-diff file Dockerfile --fail-on-diff\n\n# Compare a specific ref (tag, branch, or commit SHA)\nrails-diff file Dockerfile --ref v8.0.0\n```\n\n### Compare generator files\n\n```bash\n# Compare files that would be created by a generator\nrails-diff generated authentication\n\n# Compare files with generator arguments\nrails-diff generated scaffold Post title:string body:text\n\n# Force regenerate Rails app by clearing cache\nrails-diff generated scaffold Post --clear-cache\n\n# Skip specific files or directories during the diff\nrails-diff generated scaffold Post --skip app/views app/helpers\n\n# Fail if there are differences (useful for CI)\nrails-diff generated scaffold Post --fail-on-diff\n\n# Compare a specific ref (tag, branch, or commit SHA)\nrails-diff generated authentication --ref v8.0.0\n```\n\n### Compare dotfiles (configuration files)\n\n```bash\n# Compare configuration files like .gitignore, .rspec, .rubocop.yml\nrails-diff dotfiles\n```\n\n### Compare infrastructure files\n\nCompare 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.\n\n```bash\n# Compare all infrastructure files\nrails-diff infra\n\n# Compare only specific directories\nrails-diff infra --only bin config\n\n# Skip additional directories\nrails-diff infra --skip db\n\n# Fail if there are differences (useful for CI)\nrails-diff infra --fail-on-diff\n```\n\n### Global Options\n\nThese options can be used with any of the commands above.\n\n#### --fail-on-diff\n\nIf 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.\n\n#### --ref <ref>\n\nSpecify a tag, branch, or commit SHA to compare against. If not provided, the\nlatest commit on main will be used by default. `--commit` is kept as an alias.\n\nYou can also pass `rails --version` output directly:\n\n```bash\nrails-diff file Dockerfile --ref \"$(rails --version)\"\n```\n\n> [!NOTE]\n> When using a commit SHA, the full 40-character SHA is required (short SHAs are not supported).\n\n#### --new-app-options <options>\n\nSpecify 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.\n\nExample:\n\n```bash\nrails-diff file Dockerfile --new-app-options=\"--database=postgresql\"\n```\n\n#### --skip <files/directories>\n\nSkip specific files or directories during the diff.\n\n```bash\nrails-diff generated scaffold Post --skip app/views app/helpers\n```\n\n#### --only <files/directories>\n\nOnly include specific files or directories in the diff.\n\n```bash\nrails-diff generated scaffold Post --only app/models app/controllers\n```\n\n#### --clear-cache/--no-cache\n\nClear the cache directory to force cloning Rails and regenerating the Rails template app.\n\n```bash\nrails-diff file Dockerfile --clear-cache\n```\n\n#### --debug\n\nPrint debug information.\n\n```sh\nrails-diff file Gemfile --debug\n```\n\nor with an environment variable:\n\n```sh\nDEBUG=1 rails-diff file Gemfile\n```\n\n## How it works\n\nWhen you run the diff, it will:\n\n1. Clone the latest Rails from main branch\n1. Generate a new Rails app with the same name as yours\n1. Show you a colored diff between your file and the generated one\n\n### Cache\n\nThe gem caches the generated Rails application to avoid regenerating it on every run. The cache is automatically invalidated when:\n- Rails has new commits on main\n- The cache directory doesn't exist (or is cleared with the `--clear-cache` option)\n- You use `--new-app-options` with different options\n- You change your `~/.railsrc` file\n- You use `--ref` with a different ref\n\nYou can also force clear the cache by using the `--no-cache` option (or its alias `--clear-cache`) with any command.\n\n## Development\n\nAfter checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.\n\nTo install this gem onto your local machine, run `bundle exec rake install`.\n\n## Contributing\n\nBug reports and pull requests are welcome on GitHub at https://github.com/matheusrich/rails-diff.\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n"
  },
  {
    "path": "Rakefile",
    "content": "# frozen_string_literal: true\n\nrequire \"bundler/gem_tasks\"\nrequire \"rspec/core/rake_task\"\n\nRSpec::Core::RakeTask.new(:spec)\n\ntask default: :spec\ntask build: :spec\n"
  },
  {
    "path": "bin/console",
    "content": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\nrequire \"bundler/setup\"\nrequire \"rails/diff\"\n\n# You can add fixtures and/or initialization code here to make experimenting\n# with your gem easier. You can also use a different console, if you like.\n\nrequire \"irb\"\nIRB.start(__FILE__)\n"
  },
  {
    "path": "bin/setup",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\nIFS=$'\\n\\t'\nset -vx\n\nbundle install\n\n# Do any other automated setup that you need to do here\n"
  },
  {
    "path": "exe/rails-diff",
    "content": "#!/usr/bin/env ruby\n\n$LOAD_PATH.unshift(\"#{__dir__}/../lib\")\nrequire \"rails/diff\"\n\nRails::Diff::CLI.start(ARGV)\n"
  },
  {
    "path": "lib/rails/diff/cli.rb",
    "content": "require \"thor\"\n\nmodule Rails\n  module Diff\n    class CLI < Thor\n      class_option :no_cache, type: :boolean, desc: \"Clear cache before running\", aliases: [\"--clear-cache\"]\n      class_option :fail_on_diff, type: :boolean, desc: \"Fail if there are differences\"\n      class_option :ref, type: :string, desc: \"Compare against a specific ref (tag, branch, or commit SHA)\", aliases: [\"--commit\"]\n      class_option :new_app_options, type: :string, desc: \"Options to pass to the rails new command\"\n      class_option :debug, type: :boolean, desc: \"Print debug information\", aliases: [\"-d\"]\n\n      def self.exit_on_failure? = true\n\n      desc \"file FILE [FILE ...]\", \"Compare one or more files from your repository with Rails' generated version\"\n      def file(*files)\n        abort \"Please provide at least one file to compare\" if files.empty?\n        ENV[\"DEBUG\"] = \"true\" if options[:debug]\n\n        diff = Rails::Diff.file(\n          *files,\n          no_cache: options[:no_cache],\n          ref: options[:ref],\n          new_app_options: options[:new_app_options]\n        )\n        return if diff.empty?\n\n        options[:fail_on_diff] ? abort(diff) : puts(diff)\n      end\n\n      desc \"dotfiles\", \"Compare dotfiles in your repository with the ones generated by Rails\"\n      def dotfiles\n        dotfiles = `git ls-files --cached --others --exclude-standard -- '.*'`.split(\"\\n\")\n\n        file(*dotfiles)\n      end\n\n      desc \"generated GENERATOR [args]\", \"Compare files that would be created by a Rails generator\"\n      option :skip, type: :array, desc: \"Skip specific files or directories\", aliases: [\"-s\"], default: []\n      option :only, type: :array, desc: \"Only include specific files or directories\", aliases: [\"-o\"], default: []\n      def generated(generator_name, *args)\n        ENV[\"DEBUG\"] = \"true\" if options[:debug]\n        diff = Rails::Diff.generated(\n          generator_name,\n          *args,\n          no_cache: options[:no_cache],\n          skip: options[:skip],\n          only: options[:only],\n          ref: options[:ref],\n          new_app_options: options[:new_app_options]\n        )\n        return if diff.empty?\n\n        options[:fail_on_diff] ? abort(diff) : puts(diff)\n      end\n\n      desc \"infra\", \"Compare non-application files in your repository (everything except app/ and lib/) with the ones generated by Rails\"\n      option :skip, type: :array, desc: \"Additional files or directories to skip\", aliases: [\"-s\"], default: []\n      option :only, type: :array, desc: \"Only include specific files or directories\", aliases: [\"-o\"], default: []\n      def infra\n        ENV[\"DEBUG\"] = \"true\" if options[:debug]\n        diff = Rails::Diff.infra(\n          no_cache: options[:no_cache],\n          skip: options[:skip],\n          only: options[:only],\n          ref: options[:ref],\n          new_app_options: options[:new_app_options]\n        )\n        return if diff.empty?\n\n        options[:fail_on_diff] ? abort(diff) : puts(diff)\n      end\n\n      map %w[--version -v] => :__version\n      desc \"--version, -v\", \"print the version\"\n      def __version\n        puts VERSION\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/rails/diff/file_tracker.rb",
    "content": "# frozen_string_literal: true\n\nmodule Rails\n  module Diff\n    module FileTracker\n      DEFAULT_EXCLUSIONS = %w[.git tmp log test].freeze\n\n      def self.new_files(base_dir, only:, skip: [])\n        files_before = list_files(base_dir)\n        yield\n        files_after = list_files(base_dir, skip:, only:)\n        files_after - files_before\n      end\n\n      def self.list_files(dir, skip: [], only: [], exclusions: DEFAULT_EXCLUSIONS)\n        files = Dir.glob(\"#{dir}/**/*\", File::FNM_DOTMATCH).reject do |it|\n          File.directory?(it) ||\n            exclusions.any? { |e| it.start_with?(\"#{dir}/#{e}\") } ||\n            skip.any? { |s| it.start_with?(\"#{dir}/#{s}\") }\n        end\n\n        if only.any?\n          files.select { |it| only.any? { |o| it.start_with?(\"#{dir}/#{o}\") } }\n        else\n          files\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/rails/diff/logger.rb",
    "content": "module Rails\n  module Diff\n    module Logger\n      extend self\n\n      def info(message)\n        puts \"\\e[1;34minfo:\\e[0m\\t#{message}\"\n      end\n\n      def debug(message)\n        return unless ENV[\"DEBUG\"]\n\n        puts \"\\e[1;33mdebug:\\e[0m\\t#{message}\"\n      end\n\n      def error(label, message)\n        warn \"\\e[1;31m#{label}\\e[0m #{message}\"\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/rails/diff/rails_app_generator.rb",
    "content": "require \"digest\"\n\nmodule Rails\n  module Diff\n    class RailsAppGenerator\n      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:))\n        @new_app_options = new_app_options.to_s.split\n        @rails_repo = rails_repo\n        @ref = normalize_ref(ref)\n        @logger = logger\n        @cache_dir = cache_dir\n        clear_cache if no_cache\n      end\n\n      def clear_cache\n        logger.info \"Clearing cache\"\n        FileUtils.rm_rf(cache_dir, secure: true)\n        FileUtils.mkdir_p(cache_dir)\n      end\n\n      def create_template_app\n        return if cached_app?\n\n        create_new_rails_app\n      end\n\n      def template_app_path\n        @template_app_path ||= File.join(cache_dir, rails_cache_dir_key, rails_new_options_hash, app_name)\n      end\n\n      def install_app_dependencies\n        Dir.chdir(template_app_path) do\n          unless Shell.run!(\"bundle check\", abort: false, logger:)\n            logger.info \"Installing application dependencies\"\n            Shell.run!(\"bundle install\", logger:)\n          end\n        end\n      end\n\n      def run_generator(generator_name, *args, skip, only)\n        Dir.chdir(template_app_path) do\n          Shell.run!(\"bin/rails\", \"destroy\", generator_name, *args, logger:)\n          logger.info \"Running generator: rails generate #{generator_name} #{args.join(\" \")}\"\n\n          FileTracker\n            .new_files(template_app_path, skip:, only:) { Shell.run!(\"bin/rails\", \"generate\", generator_name, *args, logger:) }\n            .map { |it| it.delete_prefix(\"#{template_app_path}/\") }\n        end\n      end\n\n      private\n\n      attr_reader :new_app_options, :rails_repo, :logger, :cache_dir\n\n      def normalize_ref(ref)\n        if ref&.start_with?(\"Rails \")\n          \"v#{ref.delete_prefix(\"Rails \")}\"\n        else\n          ref\n        end\n      end\n\n      def ref = @ref ||= rails_repo.latest_commit\n\n      def rails_cache_dir_key = \"rails-#{ref.first(10)}\"\n\n      def railsrc_path = \"#{ENV[\"HOME\"]}/.railsrc\"\n\n      def railsrc_options\n        @railsrc_options ||= File.exist?(railsrc_path) ? File.readlines(railsrc_path) : []\n      end\n\n      def app_name = @app_name ||= File.basename(Dir.pwd)\n\n      def cached_app?\n        File.exist?(template_app_path) && rails_repo.up_to_date?\n      end\n\n      def create_new_rails_app\n        checkout_rails_ref\n        generate_app\n      end\n\n      def generate_app\n        rails_repo.install_dependencies\n        if railsrc_options.any?\n          logger.info \"Using default options from #{railsrc_path}:\\n\\t  > #{railsrc_options.join(\" \")}\"\n        end\n        rails_repo.new_app(template_app_path, rails_new_options)\n      end\n\n      def checkout_rails_ref = rails_repo.checkout(ref)\n\n      def rails_new_options\n        @rails_new_options ||= (new_app_options + railsrc_options).compact\n      end\n\n      def rails_new_options_hash\n        Digest::MD5.hexdigest(rails_new_options.join(\" \"))\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/rails/diff/rails_repo.rb",
    "content": "module Rails\n  module Diff\n    class RailsRepo\n      RAILS_REPO = \"https://github.com/rails/rails.git\"\n\n      def initialize(logger:, cache_dir: Rails::Diff::CACHE_DIR, rails_repo: RAILS_REPO)\n        @logger = logger\n        @cache_dir = cache_dir\n        @rails_repo = rails_repo\n      end\n\n      def checkout(ref)\n        on_rails_dir do\n          logger.info \"Checking out Rails (at #{ref})\"\n          unless Shell.run!(\"git\", \"checkout\", ref, abort: false, logger:)\n            Shell.run!(\"git\", \"fetch\", \"--depth\", \"1\", \"origin\", ref, logger:)\n            Shell.run!(\"git\", \"checkout\", \"FETCH_HEAD\", logger:)\n          end\n        end\n      end\n\n      def latest_commit\n        @latest_commit ||= on_rails_dir do\n          Shell.run!(\"git fetch origin main\", logger:)\n          `git rev-parse origin/main`.strip\n        end\n      end\n\n      def up_to_date?\n        File.exist?(rails_path) && on_latest_commit?\n      end\n\n      def install_dependencies\n        within \"railties\" do\n          unless Shell.run!(\"bundle check\", abort: false, logger:)\n            logger.info \"Installing Rails dependencies\"\n            Shell.run!(\"bundle\", \"config\", \"set\", \"--local\", \"without\", \"db\", logger:)\n            Shell.run!(\"bundle\", \"install\", logger:)\n          end\n        end\n      end\n\n      def new_app(name, options)\n        within \"railties\" do\n          command = rails_new_command(name, options)\n          logger.info \"Generating new Rails application\\n\\t  > #{command.join(\" \")}\"\n          Shell.run!(*command, logger:)\n        end\n      end\n\n      def within(dir, &block) = on_rails_dir { Dir.chdir(dir, &block) }\n\n      private\n\n      attr_reader :logger, :cache_dir, :rails_repo\n\n      def rails_path\n        File.join(cache_dir, \"rails\")\n      end\n\n      def on_latest_commit?\n        if current_commit == latest_commit\n          true\n        else\n          remove_repo\n          false\n        end\n      end\n\n      def on_rails_dir(&block)\n        clone_repo unless File.exist?(rails_path)\n        Dir.chdir(rails_path, &block)\n      end\n\n      def current_commit = on_rails_dir { `git rev-parse HEAD`.strip }\n\n      def remove_repo = FileUtils.rm_rf(rails_path, secure: true)\n\n      def clone_repo\n        logger.info \"Cloning Rails repository\"\n        Shell.run!(\"git\", \"clone\", \"--depth\", \"1\", rails_repo, rails_path, logger:)\n      end\n\n      def rails_new_command(name, options)\n        [\n          \"bundle\", \"exec\", \"rails\", \"new\", name,\n          \"--main\", \"--skip-bundle\", \"--force\", \"--quiet\", *options\n        ]\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/rails/diff/shell.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"open3\"\n\nmodule Rails\n  module Diff\n    module Shell\n      extend self\n\n      def run!(*cmd, logger:, abort: true)\n        _, stderr, status = Open3.capture3(*cmd)\n        logger.debug(cmd.join(\" \"))\n        if status.success?\n          true\n        elsif abort\n          logger.error(\"Command failed:\", cmd.join(\" \"))\n          abort stderr\n        else\n          false\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "lib/rails/diff/version.rb",
    "content": "# frozen_string_literal: true\n\nmodule Rails\n  module Diff\n    VERSION = \"0.8.0\"\n  end\nend\n"
  },
  {
    "path": "lib/rails/diff.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails\"\nrequire \"difftastic\"\nrequire \"fileutils\"\nrequire_relative \"diff/cli\"\nrequire_relative \"diff/file_tracker\"\nrequire_relative \"diff/logger\"\nrequire_relative \"diff/shell\"\nrequire_relative \"diff/rails_app_generator\"\nrequire_relative \"diff/rails_repo\"\nrequire_relative \"diff/version\"\n\nmodule Rails\n  module Diff\n    CACHE_DIR = File.expand_path(\"#{ENV[\"HOME\"]}/.rails-diff/cache\")\n\n    class << self\n      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)\n        app_generator.create_template_app\n\n        files\n          .filter_map { |it| diff_with_header(it, app_generator.template_app_path, differ_class:) }\n          .join(\"\\n\")\n      end\n\n      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)\n        app_generator.create_template_app\n        app_generator.install_app_dependencies\n\n        app_generator.run_generator(generator_name, *args, skip, only)\n          .filter_map { |it| diff_with_header(it, app_generator.template_app_path, differ_class:) }\n          .join(\"\\n\\n\")\n      end\n\n      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)\n        app_generator.create_template_app\n\n        default_skip = %w[app lib]\n        effective_skip = (default_skip + skip).uniq\n\n        FileTracker.list_files(app_generator.template_app_path, skip: effective_skip, only:)\n          .map { |f| f.delete_prefix(\"#{app_generator.template_app_path}/\") }\n          .filter_map { |it| diff_with_header(it, app_generator.template_app_path, differ_class:) }\n          .join(\"\\n\\n\")\n      end\n\n      private\n\n      def diff_with_header(file, template_app_path, differ_class:)\n        diff = diff_file(file, template_app_path, differ_class:)\n        return if diff.empty?\n\n        header = \"#{file} diff:\"\n        [header, \"=\" * header.size, diff].join(\"\\n\")\n      end\n\n      def diff_file(file, template_app_path, differ_class:)\n        rails_file = File.join(template_app_path, file)\n        repo_file = File.join(Dir.pwd, file)\n\n        return \"File not found in the Rails template\" unless File.exist?(rails_file)\n        return \"File not found in your repository\" unless File.exist?(repo_file)\n\n        differ = differ_class.new(\n          color: :always,\n          left_label: \"Rails File (#{file})\",\n          right_label: \"Repo File (#{file})\"\n        )\n\n        differ.diff_files(rails_file, repo_file).chomp\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "rails-diff.gemspec",
    "content": "# frozen_string_literal: true\n\nrequire_relative \"lib/rails/diff/version\"\n\nGem::Specification.new do |spec|\n  spec.name = \"rails-diff\"\n  spec.version = Rails::Diff::VERSION\n  spec.authors = [\"Matheus Richard\"]\n  spec.email = [\"matheusrichardt@gmail.com\"]\n\n  spec.summary = \"Compare Rails-generated files with the ones in your repository\"\n  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.\"\n  spec.homepage = \"https://github.com/matheusrich/rails-diff\"\n  spec.license = \"MIT\"\n  spec.required_ruby_version = \">= 3.2\"\n\n  spec.metadata[\"allowed_push_host\"] = \"https://rubygems.org\"\n\n  spec.metadata[\"homepage_uri\"] = spec.homepage\n  spec.metadata[\"source_code_uri\"] = \"https://github.com/matheusrich/rails-diff\"\n  spec.metadata[\"changelog_uri\"] = \"https://github.com/matheusrich/rails-diff/blob/main/CHANGELOG.md\"\n\n  # Specify which files should be added to the gem when it is released.\n  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.\n  gemspec = File.basename(__FILE__)\n  spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|\n    ls.readlines(\"\\x0\", chomp: true).reject do |f|\n      (f == gemspec) ||\n        f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])\n    end\n  end\n  spec.bindir = \"exe\"\n  spec.executables = spec.files.grep(%r{\\Aexe/}) { |f| File.basename(f) }\n  spec.require_paths = [\"lib\"]\n\n  spec.add_dependency \"rails\", \">= 7.0\"\n  spec.add_dependency \"difftastic\", \"~> 0.8\"\n  spec.add_dependency \"thor\", \"~> 1.0\"\n\n  # For more information and examples about making a new gem, check out our\n  # guide at: https://bundler.io/guides/creating_gem.html\nend\n"
  },
  {
    "path": "spec/integration/rails/diff/file_tracker_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails/diff/file_tracker\"\n\nRSpec.describe Rails::Diff::FileTracker do\n  let(:temp_dir) { Dir.mktmpdir }\n\n  after do\n    FileUtils.remove_entry(temp_dir)\n  end\n\n  it \"tracks newly created files\" do\n    FileUtils.touch(\"#{temp_dir}/file1.rb\")\n\n    new_files = described_class.new_files(temp_dir, only: []) do\n      FileUtils.touch(\"#{temp_dir}/file2.rb\")\n      FileUtils.touch(\"#{temp_dir}/file3.rb\")\n    end\n\n    expect(new_files).to contain_exactly(\"#{temp_dir}/file2.rb\", \"#{temp_dir}/file3.rb\")\n  end\n\n  it \"excludes skipped files\" do\n    FileUtils.touch(\"#{temp_dir}/file1.rb\")\n\n    new_files = described_class.new_files(temp_dir, skip: [\"file2.rb\"], only: []) do\n      FileUtils.touch(\"#{temp_dir}/file2.rb\")\n      FileUtils.touch(\"#{temp_dir}/file3.rb\")\n    end\n\n    expect(new_files).to contain_exactly(\"#{temp_dir}/file3.rb\")\n  end\n\n  it \"handles files with only option\" do\n    FileUtils.touch(\"#{temp_dir}/file1.rb\")\n\n    new_files = described_class.new_files(temp_dir, only: [\"file2.rb\"]) do\n      FileUtils.touch(\"#{temp_dir}/file2.rb\")\n      FileUtils.touch(\"#{temp_dir}/file3.rb\")\n    end\n\n    expect(new_files).to contain_exactly(\"#{temp_dir}/file2.rb\")\n  end\n\n  it \"ignores files in special directories\" do\n    FileUtils.mkdir_p(\"#{temp_dir}/.git\")\n    FileUtils.mkdir_p(\"#{temp_dir}/tmp\")\n    FileUtils.mkdir_p(\"#{temp_dir}/log\")\n    FileUtils.touch(\"#{temp_dir}/file1.rb\")\n\n    new_files = described_class.new_files(temp_dir, only: []) do\n      FileUtils.touch(\"#{temp_dir}/.git/config\")\n      FileUtils.touch(\"#{temp_dir}/tmp/cache\")\n      FileUtils.touch(\"#{temp_dir}/log/development.log\")\n      FileUtils.touch(\"#{temp_dir}/file2.rb\")\n    end\n\n    expect(new_files).to contain_exactly(\"#{temp_dir}/file2.rb\")\n  end\n\n  it \"handles nested directories\" do\n    FileUtils.touch(\"#{temp_dir}/file1.rb\")\n\n    new_files = described_class.new_files(temp_dir, only: []) do\n      FileUtils.mkdir_p(\"#{temp_dir}/nested/dir\")\n      FileUtils.touch(\"#{temp_dir}/nested/file2.rb\")\n      FileUtils.touch(\"#{temp_dir}/nested/dir/file3.rb\")\n    end\n\n    expect(new_files).to contain_exactly(\"#{temp_dir}/nested/file2.rb\", \"#{temp_dir}/nested/dir/file3.rb\")\n  end\nend\n"
  },
  {
    "path": "spec/lib/rails/diff/cli_spec.rb",
    "content": "# frozen_string_literal: true\n\n# require \"spec_helper\"\n\nRSpec.describe Rails::Diff::CLI do\n  describe \"#file\" do\n    context \"when --fail-on-diff is not specified\" do\n      it \"exits successfully\" do\n        allow(Rails::Diff).to receive(:file).with(\"some_file.rb\", kind_of(Hash)).and_return(\"diff output\")\n\n        expect {\n          described_class.start([\"file\", \"some_file.rb\"])\n        }.not_to raise_error\n      end\n    end\n\n    context \"when --fail-on-diff is specified\" do\n      it \"exits with an error code\" do\n        allow(Rails::Diff).to receive(:file).with(\"some_file.rb\", kind_of(Hash)).and_return(\"diff output\")\n\n        expect {\n          described_class.start([\"file\", \"some_file.rb\", \"--fail-on-diff\"])\n        }.to raise_error(SystemExit)\n      end\n    end\n  end\n\n  describe \"#infra\" do\n    context \"when --fail-on-diff is not specified\" do\n      it \"exits successfully\" do\n        allow(Rails::Diff).to receive(:infra).with(kind_of(Hash)).and_return(\"diff output\")\n\n        expect {\n          described_class.start([\"infra\"])\n        }.not_to raise_error\n      end\n    end\n\n    context \"when --fail-on-diff is specified\" do\n      it \"exits with an error code\" do\n        allow(Rails::Diff).to receive(:infra).with(kind_of(Hash)).and_return(\"diff output\")\n\n        expect {\n          described_class.start([\"infra\", \"--fail-on-diff\"])\n        }.to raise_error(SystemExit)\n      end\n    end\n\n    it \"passes skip and only options to Rails::Diff.infra\" do\n      expect(Rails::Diff).to receive(:infra).with(hash_including(skip: [\"config\"], only: [\"bin\"])).and_return(\"\")\n\n      described_class.start([\"infra\", \"--skip\", \"config\", \"--only\", \"bin\"])\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lib/rails/diff/rails_app_generator_spec.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"rails/diff/rails_app_generator\"\n\nRSpec.describe Rails::Diff::RailsAppGenerator do\n  let(:cache_dir) { Dir.mktmpdir }\n  let(:work_dir) { Dir.mktmpdir(\"myapp\") }\n\n  around do |example|\n    original_home = ENV[\"HOME\"]\n    ENV[\"HOME\"] = work_dir\n    Dir.chdir(work_dir) { example.run }\n  ensure\n    ENV[\"HOME\"] = original_home\n    FileUtils.rm_rf(cache_dir)\n    FileUtils.rm_rf(work_dir)\n  end\n\n  def build_generator(ref: \"abc1234567890def\", logger: spy, rails_repo: spy(latest_commit: ref, up_to_date?: false), **options)\n    described_class.new(ref:, logger:, cache_dir:, rails_repo:, **options)\n  end\n\n  def stub_command(*args, result: true, **opts)\n    allow(Rails::Diff::Shell).to receive(:run!).with(*args, **opts).and_return(result)\n  end\n\n  describe \"#create_template_app\" do\n    it \"checks out the ref, installs dependencies, and generates a new app\" do\n      repo = spy(latest_commit: \"abc1234567890def\", up_to_date?: false)\n      generator = build_generator(rails_repo: repo)\n\n      generator.create_template_app\n\n      expect(repo).to have_received(:checkout).with(\"abc1234567890def\")\n      expect(repo).to have_received(:install_dependencies)\n      expect(repo).to have_received(:new_app).with(generator.template_app_path, [])\n    end\n\n    it \"passes new_app_options to rails new\" do\n      repo = spy(latest_commit: \"abc1234567890def\", up_to_date?: false)\n      generator = build_generator(rails_repo: repo, new_app_options: \"--api --skip-test\")\n\n      generator.create_template_app\n\n      expect(repo).to have_received(:new_app).with(\n        generator.template_app_path,\n        [\"--api\", \"--skip-test\"]\n      )\n    end\n\n    it \"merges options from .railsrc when it exists\" do\n      File.write(File.join(work_dir, \".railsrc\"), \"--skip-test\\n\")\n      repo = spy(latest_commit: \"abc1234567890def\", up_to_date?: false)\n      logger = spy\n      generator = build_generator(rails_repo: repo, logger:, new_app_options: \"--api\")\n\n      generator.create_template_app\n\n      expect(repo).to have_received(:new_app).with(\n        generator.template_app_path,\n        [\"--api\", \"--skip-test\\n\"]\n      )\n      expect(logger).to have_received(:info).with(/Using default options from/)\n    end\n\n    it \"defaults ref to the latest commit from the rails repo\" do\n      repo = spy(latest_commit: \"abc1234567890def\", up_to_date?: false)\n      generator = described_class.new(logger: spy, cache_dir:, rails_repo: repo)\n\n      generator.create_template_app\n\n      expect(repo).to have_received(:checkout).with(\"abc1234567890def\")\n    end\n\n    it \"skips creation when the app is cached and up to date\" do\n      repo = spy(latest_commit: \"abc1234567890def\", up_to_date?: true)\n      generator = build_generator(rails_repo: repo)\n      FileUtils.mkdir_p(generator.template_app_path)\n\n      generator.create_template_app\n\n      expect(repo).not_to have_received(:checkout)\n      expect(repo).not_to have_received(:new_app)\n    end\n\n    it \"recreates the app when the repo is outdated\" do\n      repo = spy(latest_commit: \"abc1234567890def\", up_to_date?: false)\n      generator = build_generator(rails_repo: repo)\n      FileUtils.mkdir_p(generator.template_app_path)\n\n      generator.create_template_app\n\n      expect(repo).to have_received(:checkout).with(\"abc1234567890def\")\n      expect(repo).to have_received(:new_app)\n    end\n  end\n\n  describe \"ref normalization\" do\n    it \"converts 'Rails X.Y.Z' format to 'vX.Y.Z'\" do\n      repo = spy(latest_commit: \"v7.2.3\", up_to_date?: false)\n      generator = build_generator(ref: \"Rails 7.2.3\", rails_repo: repo)\n\n      generator.create_template_app\n\n      expect(repo).to have_received(:checkout).with(\"v7.2.3\")\n    end\n\n    it \"passes through refs that don't start with 'Rails '\" do\n      repo = spy(latest_commit: \"abc1234567890def\", up_to_date?: false)\n      generator = build_generator(ref: \"v7.2.3\", rails_repo: repo)\n\n      generator.create_template_app\n\n      expect(repo).to have_received(:checkout).with(\"v7.2.3\")\n    end\n  end\n\n  describe \"#clear_cache\" do\n    it \"removes and recreates the cache directory\" do\n      generator = build_generator\n      marker = File.join(cache_dir, \"some_cached_file\")\n      FileUtils.touch(marker)\n\n      generator.clear_cache\n\n      expect(File.exist?(marker)).to eq(false)\n      expect(Dir.exist?(cache_dir)).to eq(true)\n    end\n\n    it \"is called on initialization when no_cache is true\" do\n      repo = spy(latest_commit: \"abc1234567890def\", up_to_date?: false)\n      marker = File.join(cache_dir, \"some_cached_file\")\n      FileUtils.touch(marker)\n\n      build_generator(rails_repo: repo, no_cache: true)\n\n      expect(File.exist?(marker)).to eq(false)\n      expect(Dir.exist?(cache_dir)).to eq(true)\n    end\n  end\n\n  describe \"#template_app_path\" do\n    it \"includes the ref prefix in the path\" do\n      generator = build_generator\n\n      expect(generator.template_app_path).to include(\"rails-abc1234567\")\n    end\n\n    it \"uses the current directory name as the app name\" do\n      generator = build_generator\n\n      expect(generator.template_app_path).to end_with(File.basename(work_dir))\n    end\n\n    it \"generates different paths for different options\" do\n      gen1 = build_generator(new_app_options: \"--api\")\n      gen2 = build_generator(new_app_options: \"--skip-test\")\n\n      expect(gen1.template_app_path).not_to eq(gen2.template_app_path)\n    end\n  end\n\n  describe \"#install_app_dependencies\" do\n    it \"runs bundle install when bundle check fails\" do\n      logger = spy\n      generator = build_generator(logger:)\n      FileUtils.mkdir_p(generator.template_app_path)\n\n      stub_command(\"bundle check\", abort: false, logger:, result: false)\n      stub_command(\"bundle install\", logger:)\n\n      generator.install_app_dependencies\n\n      expect(Rails::Diff::Shell).to have_received(:run!).with(\"bundle install\", logger:)\n    end\n\n    it \"skips bundle install when bundle check passes\" do\n      logger = spy\n      generator = build_generator(logger:)\n      FileUtils.mkdir_p(generator.template_app_path)\n\n      stub_command(\"bundle check\", abort: false, logger:, result: true)\n\n      generator.install_app_dependencies\n\n      expect(Rails::Diff::Shell).not_to have_received(:run!).with(\"bundle install\", logger:)\n    end\n  end\n\n  describe \"#run_generator\" do\n    it \"destroys existing files and regenerates them\" do\n      logger = spy\n      generator = build_generator(logger:)\n      FileUtils.mkdir_p(generator.template_app_path)\n\n      stub_command(\"bin/rails\", \"destroy\", \"model\", \"User\", logger:)\n      stub_command(\"bin/rails\", \"generate\", \"model\", \"User\", logger:)\n      allow(Rails::Diff::FileTracker).to receive(:new_files)\n        .and_yield\n        .and_return([\"#{generator.template_app_path}/app/models/user.rb\"])\n\n      generator.run_generator(\"model\", \"User\", [], [])\n\n      expect(Rails::Diff::Shell).to have_received(:run!)\n        .with(\"bin/rails\", \"destroy\", \"model\", \"User\", logger:)\n      expect(Rails::Diff::Shell).to have_received(:run!)\n        .with(\"bin/rails\", \"generate\", \"model\", \"User\", logger:)\n    end\n\n    it \"returns relative file paths\" do\n      generator = build_generator\n      FileUtils.mkdir_p(generator.template_app_path)\n\n      stub_command(\"bin/rails\", \"destroy\", \"model\", \"User\", logger: anything)\n      stub_command(\"bin/rails\", \"generate\", \"model\", \"User\", logger: anything)\n      allow(Rails::Diff::FileTracker).to receive(:new_files)\n        .and_return([\n          \"#{generator.template_app_path}/app/models/user.rb\",\n          \"#{generator.template_app_path}/db/migrate/001_create_users.rb\"\n        ])\n\n      result = generator.run_generator(\"model\", \"User\", [], [])\n\n      expect(result).to eq([\"app/models/user.rb\", \"db/migrate/001_create_users.rb\"])\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lib/rails/diff/rails_repo_spec.rb",
    "content": "# Helper indices for commits:\n#   @git_repo.commits[0] => 'add railties dir' (initial commit)\n#   @git_repo.commits[1] => 'commit1'\n#   @git_repo.commits.last => 'commit2'\n\nrequire \"rails/diff/rails_repo\"\nrequire \"fileutils\"\nrequire \"tmpdir\"\n\nRSpec.describe Rails::Diff::RailsRepo do\n  let(:logger) { spy }\n  let(:cache_dir) { Dir.mktmpdir }\n  let(:rails_path) { File.join(cache_dir, \"rails\") }\n\n  before(:all) do\n    @git_repo = GitRepo.new.tap do |repo|\n      repo.add_commit(\"commit1\")\n      repo.add_commit(\"commit2\")\n    end\n  end\n\n  after do\n    FileUtils.rm_rf(cache_dir)\n    @git_repo.cleanup\n  end\n\n  describe \"#up_to_date?\" do\n    it \"returns false when repo is not cloned yet\" do\n      repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo)\n\n      result = repo.up_to_date?\n\n      expect(result).to eq false\n    end\n\n    it \"returns true when repo is on the latest commit\" do\n      @git_repo.clone_at_commit(@git_repo.commits.last, rails_path)\n      repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo)\n\n      result = repo.up_to_date?\n\n      expect(result).to eq true\n    end\n\n    it \"returns false and removes the repo when repo is on an old commit\" do\n      @git_repo.clone_at_commit(@git_repo.commits.first, rails_path)\n      repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo)\n\n      result = repo.up_to_date?\n\n      expect(result).to eq false\n      expect(File.exist?(rails_path)).to eq false\n    end\n  end\n\n  describe \"#latest_commit\" do\n    it \"returns the latest commit SHA from the remote repo\" do\n      repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo)\n\n      latest = repo.latest_commit\n\n      expect(latest).to eq @git_repo.commits.last\n    end\n\n    it \"returns the new latest commit after a new commit is pushed\" do\n      new_commit = @git_repo.add_commit(\"commit3\")\n      repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo)\n\n      latest = repo.latest_commit\n\n      expect(latest).to eq new_commit\n    end\n  end\n\n  describe \"#checkout\" do\n    it \"checks out the given commit in the repo\" do\n      @git_repo.clone_at_commit(@git_repo.commits.last, rails_path)\n      repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo)\n\n      # Checkout the first file commit (not the initial railties commit)\n      repo.checkout(@git_repo.commits[1])\n\n      # Verify HEAD is now at the first file commit\n      current = Dir.chdir(rails_path) { `git rev-parse HEAD`.strip }\n      expect(current).to eq @git_repo.commits[1]\n    end\n\n    it \"checks out a tag in a shallow clone\" do\n      tagged_commit = @git_repo.commits.last\n      @git_repo.add_tag(\"v1.0.0\")\n      @git_repo.add_commit(\"after tag\")\n      @git_repo.shallow_clone(rails_path)\n      repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo)\n\n      repo.checkout(\"v1.0.0\")\n\n      current = Dir.chdir(rails_path) { `git rev-parse HEAD`.strip }\n      expect(current).to eq tagged_commit\n    end\n\n    it \"logs the checkout info message\" do\n      @git_repo.clone_at_commit(@git_repo.commits.last, rails_path)\n      repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo)\n\n      repo.checkout(@git_repo.commits[1])\n\n      expect(logger).to have_received(:info)\n        .with(/Checking out Rails \\(at #{@git_repo.commits[1]}\\)/)\n    end\n  end\n\n  describe \"#install_dependencies\" do\n    it \"runs bundle check and bundle install excluding db group if needed, and logs appropriately\" do\n      @git_repo.clone_at_commit(@git_repo.commits[0], rails_path)\n      repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo)\n\n      # Simulate bundle check failing, so bundle install is needed\n      allow(Rails::Diff::Shell).to receive(:run!).with(\"bundle check\", abort: false, logger:).and_return(false)\n      allow(Rails::Diff::Shell).to receive(:run!).with(\"bundle\", \"config\", \"set\", \"--local\", \"without\", \"db\", logger:).and_return(true)\n      allow(Rails::Diff::Shell).to receive(:run!).with(\"bundle\", \"install\", logger:).and_return(true)\n\n      repo.install_dependencies\n\n      expect(logger).to have_received(:info).with(\"Installing Rails dependencies\")\n      expect(Rails::Diff::Shell).to have_received(:run!).with(\"bundle\", \"config\", \"set\", \"--local\", \"without\", \"db\", logger:)\n      expect(Rails::Diff::Shell).to have_received(:run!).with(\"bundle\", \"install\", logger:)\n    end\n\n    it \"does not run bundle install if bundle check passes\" do\n      @git_repo.clone_at_commit(@git_repo.commits[0], rails_path)\n      repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo)\n\n      # Simulate bundle check passing\n      allow(Rails::Diff::Shell).to receive(:run!).with(\"bundle check\", abort: false, logger:).and_return(true)\n\n      repo.install_dependencies\n\n      expect(logger).not_to have_received(:info).with(\"Installing Rails dependencies\")\n    end\n  end\n\n  describe \"#new_app\" do\n    it \"runs the rails new command with the correct arguments and logs the command\" do\n      @git_repo.clone_at_commit(@git_repo.commits.last, rails_path)\n      repo = described_class.new(logger:, cache_dir:, rails_repo: @git_repo.remote_repo)\n\n      allow(Rails::Diff::Shell).to receive(:run!).and_return(true)\n      app_name = \"myapp\"\n      options = [\"--skip-test\"]\n\n      repo.new_app(app_name, options)\n\n      expected_command = [\n        \"bundle\", \"exec\", \"rails\", \"new\", app_name,\n        \"--main\", \"--skip-bundle\", \"--force\", \"--quiet\", *options\n      ]\n      expect(Rails::Diff::Shell).to have_received(:run!).with(*expected_command, logger:)\n      expect(logger).to have_received(:info).with(/Generating new Rails application/)\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lib/rails/diff_spec.rb",
    "content": "# frozen_string_literal: true\n\nRSpec.describe Rails::Diff do\n  let(:template_dir) { Dir.mktmpdir }\n  let(:repo_dir) { Dir.mktmpdir(\"repo\") }\n\n  around do |example|\n    Dir.chdir(repo_dir) { example.run }\n  ensure\n    FileUtils.rm_rf(template_dir)\n    FileUtils.rm_rf(repo_dir)\n  end\n\n  def mock_generator(template_path: template_dir)\n    spy(template_app_path: template_path)\n  end\n\n  def fake_differ_class(output: \"some diff output\")\n    Class.new do\n      define_method(:initialize) { |**| }\n      define_method(:diff_files) { |*, **| \"#{output}\\n\" }\n    end\n  end\n\n  def write_file(dir, path, content)\n    full_path = File.join(dir, path)\n    FileUtils.mkdir_p(File.dirname(full_path))\n    File.write(full_path, content)\n  end\n\n  describe \".file\" do\n    it \"returns diff output when files differ\" do\n      write_file(template_dir, \"Gemfile\", \"gem 'rails'\\n\")\n      write_file(repo_dir, \"Gemfile\", \"gem 'rails'\\ngem 'sidekiq'\\n\")\n\n      result = described_class.file(\"Gemfile\", app_generator: mock_generator, differ_class: fake_differ_class(output: \"line changed\"))\n\n      expect(result).to include(\"Gemfile diff:\")\n      expect(result).to include(\"line changed\")\n    end\n\n    it \"reports when file is missing from the Rails template\" do\n      write_file(repo_dir, \"custom.rb\", \"# custom file\\n\")\n\n      result = described_class.file(\"custom.rb\", app_generator: mock_generator, differ_class: fake_differ_class)\n\n      expect(result).to include(\"File not found in the Rails template\")\n    end\n\n    it \"reports when file is missing from the repository\" do\n      write_file(template_dir, \"Gemfile\", \"gem 'rails'\\n\")\n\n      result = described_class.file(\"Gemfile\", app_generator: mock_generator, differ_class: fake_differ_class)\n\n      expect(result).to include(\"File not found in your repository\")\n    end\n\n    it \"returns empty string when files are identical\" do\n      write_file(template_dir, \"Gemfile\", \"gem 'rails'\\n\")\n      write_file(repo_dir, \"Gemfile\", \"gem 'rails'\\n\")\n\n      result = described_class.file(\"Gemfile\", app_generator: mock_generator, differ_class: fake_differ_class(output: \"\"))\n\n      expect(result).to eq(\"\")\n    end\n\n    it \"normalizes 'Rails X.Y.Z' ref format before checking out\" do\n      repo = spy(latest_commit: \"v7.2.3\", up_to_date?: false)\n      generator = Rails::Diff::RailsAppGenerator.new(\n        ref: \"Rails 7.2.3\",\n        logger: spy,\n        cache_dir: template_dir,\n        rails_repo: repo\n      )\n\n      write_file(template_dir, \"Gemfile\", \"gem 'rails'\\n\")\n      write_file(repo_dir, \"Gemfile\", \"gem 'rails'\\n\")\n\n      described_class.file(\"Gemfile\", app_generator: generator, differ_class: fake_differ_class(output: \"\"))\n\n      expect(repo).to have_received(:checkout).with(\"v7.2.3\")\n    end\n\n    it \"calls create_template_app on the generator\" do\n      generator = mock_generator\n      write_file(template_dir, \"Gemfile\", \"gem 'rails'\\n\")\n      write_file(repo_dir, \"Gemfile\", \"gem 'rails'\\n\")\n\n      described_class.file(\"Gemfile\", app_generator: generator, differ_class: fake_differ_class(output: \"\"))\n\n      expect(generator).to have_received(:create_template_app)\n    end\n  end\n\n  describe \".infra\" do\n    it \"skips app/ and lib/ directories by default\" do\n      write_file(template_dir, \"app/models/user.rb\", \"class User; end\\n\")\n      write_file(template_dir, \"lib/tasks.rb\", \"# tasks\\n\")\n      write_file(template_dir, \"config/routes.rb\", \"# routes\\n\")\n      write_file(repo_dir, \"config/routes.rb\", \"# different routes\\n\")\n\n      result = described_class.infra(app_generator: mock_generator, differ_class: fake_differ_class(output: \"routes changed\"))\n\n      expect(result).to include(\"config/routes.rb diff:\")\n      expect(result).not_to include(\"app/models\")\n      expect(result).not_to include(\"lib/tasks\")\n    end\n\n    it \"merges user skip options with defaults\" do\n      write_file(template_dir, \"config/routes.rb\", \"# routes\\n\")\n      write_file(template_dir, \"bin/rails\", \"#!/usr/bin/env ruby\\n\")\n      write_file(repo_dir, \"config/routes.rb\", \"# different\\n\")\n      write_file(repo_dir, \"bin/rails\", \"# different\\n\")\n\n      result = described_class.infra(skip: [\"config\"], app_generator: mock_generator, differ_class: fake_differ_class(output: \"bin changed\"))\n\n      expect(result).not_to include(\"config/routes.rb\")\n      expect(result).to include(\"bin/rails diff:\")\n    end\n  end\n\n  describe \".generated\" do\n    it \"installs dependencies and runs the generator\" do\n      generator = mock_generator\n      allow(generator).to receive(:run_generator).and_return([])\n\n      described_class.generated(\"model\", \"User\", app_generator: generator, differ_class: fake_differ_class)\n\n      expect(generator).to have_received(:create_template_app)\n      expect(generator).to have_received(:install_app_dependencies)\n      expect(generator).to have_received(:run_generator).with(\"model\", \"User\", [], [])\n    end\n\n    it \"diffs generated files\" do\n      generator = mock_generator\n      write_file(template_dir, \"app/models/user.rb\", \"class User; end\\n\")\n      write_file(repo_dir, \"app/models/user.rb\", \"class User < ApplicationRecord; end\\n\")\n      allow(generator).to receive(:run_generator).and_return([\"app/models/user.rb\"])\n\n      result = described_class.generated(\"model\", \"User\", app_generator: generator, differ_class: fake_differ_class(output: \"model changed\"))\n\n      expect(result).to include(\"app/models/user.rb diff:\")\n    end\n  end\nend\n"
  },
  {
    "path": "spec/spec_helper.rb",
    "content": "# frozen_string_literal: true\n\nrequire \"simplecov\"\nSimpleCov.start do\n  enable_coverage :branch\n  add_filter \"/spec/\"\nend\n\nrequire \"rails/diff\"\nDir[\"./spec/support/**/*.rb\"].sort.each { |f| require f }\n\nRSpec.configure do |config|\n  # Enable flags like --only-failures and --next-failure\n  config.example_status_persistence_file_path = \".rspec_status\"\n\n  # Disable RSpec exposing methods globally on `Module` and `main`\n  config.disable_monkey_patching!\n\n  config.expect_with :rspec do |c|\n    c.syntax = :expect\n  end\nend\n"
  },
  {
    "path": "spec/support/git_repo.rb",
    "content": "require \"fileutils\"\nrequire \"tmpdir\"\n\nclass GitRepo\n  attr_reader :remote_repo, :commits\n\n  # Initializes and creates a bare remote repo and a working repo with main branch\n  def initialize\n    @remote_dir = Dir.mktmpdir\n    @remote_repo = File.join(@remote_dir, \"origin.git\")\n    Dir.chdir(@remote_dir) { `git init --bare --initial-branch=main origin.git > /dev/null 2>&1` }\n\n    @commits = []\n    @work_dir = Dir.mktmpdir\n    Dir.chdir(@work_dir) do\n      `git clone #{@remote_repo} . > /dev/null 2>&1`\n      `git checkout -b main > /dev/null 2>&1`\n      FileUtils.mkdir_p(\"railties\")\n      File.write(\"railties/README\", \"keep\")\n      `git add railties > /dev/null 2>&1`\n\n      `git config user.email 'test@example.com'`\n      `git config user.name 'Test User'`\n\n      commit_result = `git commit -m \"add railties dir\" 2>&1`\n      raise \"Initial commit failed: #{commit_result}\" unless $?.success?\n      @commits << `git rev-parse HEAD`.strip\n      `git push -u origin main > /dev/null 2>&1`\n    end\n  end\n\n  def add_commit(message)\n    Dir.chdir(@work_dir) do\n      `git commit --allow-empty -n -m \"#{message}\" > /dev/null 2>&1`\n      sha = `git rev-parse HEAD`.strip\n      @commits << sha\n      `git push origin main > /dev/null 2>&1`\n      `git fetch origin main > /dev/null 2>&1`\n    end\n\n    @commits.last\n  end\n\n  def add_tag(tag_name)\n    Dir.chdir(@work_dir) do\n      `git tag #{tag_name} > /dev/null 2>&1`\n      `git push origin #{tag_name} > /dev/null 2>&1`\n    end\n  end\n\n  def shallow_clone(dest_path)\n    `git clone --depth 1 file://#{@remote_repo} #{dest_path} > /dev/null 2>&1`\n  end\n\n  def clone_at_commit(commit_sha, dest_path)\n    `git clone #{@remote_repo} #{dest_path} > /dev/null 2>&1`\n    Dir.chdir(dest_path) do\n      `git checkout #{commit_sha} > /dev/null 2>&1`\n      `git fetch origin main > /dev/null 2>&1`\n    end\n  end\n\n  def cleanup\n    Dir.chdir(@work_dir) do\n      `git reset --hard main > /dev/null 2>&1`\n      `git clean -fdx > /dev/null 2>&1`\n    end\n  end\nend\n"
  }
]