Full Code of MatheusRich/rails-diff for AI

main bbf7ce91f0cc cached
28 files
49.9 KB
13.8k tokens
83 symbols
1 requests
Download .txt
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 <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 <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 <files/directories>

Skip specific files or directories during the diff.

```bash
rails-diff generated scaffold Post --skip app/views app/helpers
```

#### --only <files/directories>

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
Download .txt
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
Download .txt
SYMBOL INDEX (83 symbols across 11 files)

FILE: lib/rails/diff.rb
  type Rails (line 14) | module Rails
    type Diff (line 15) | module Diff
      function file (line 19) | def file(*files, no_cache: false, ref: nil, new_app_options: nil, ap...
      function generated (line 27) | def generated(generator_name, *args, no_cache: false, skip: [], only...
      function infra (line 36) | def infra(no_cache: false, skip: [], only: [], ref: nil, new_app_opt...
      function diff_with_header (line 50) | def diff_with_header(file, template_app_path, differ_class:)
      function diff_file (line 58) | def diff_file(file, template_app_path, differ_class:)

FILE: lib/rails/diff/cli.rb
  type Rails (line 3) | module Rails
    type Diff (line 4) | module Diff
      class CLI (line 5) | class CLI < Thor
        method exit_on_failure? (line 12) | def self.exit_on_failure? = true
        method file (line 15) | def file(*files)
        method dotfiles (line 31) | def dotfiles
        method generated (line 40) | def generated(generator_name, *args)
        method infra (line 59) | def infra
        method __version (line 75) | def __version

FILE: lib/rails/diff/file_tracker.rb
  type Rails (line 3) | module Rails
    type Diff (line 4) | module Diff
      type FileTracker (line 5) | module FileTracker
        function new_files (line 8) | def self.new_files(base_dir, only:, skip: [])
        function list_files (line 15) | def self.list_files(dir, skip: [], only: [], exclusions: DEFAULT_E...

FILE: lib/rails/diff/logger.rb
  type Rails (line 1) | module Rails
    type Diff (line 2) | module Diff
      type Logger (line 3) | module Logger
        function info (line 6) | def info(message)
        function debug (line 10) | def debug(message)
        function error (line 16) | def error(label, message)

FILE: lib/rails/diff/rails_app_generator.rb
  type Rails (line 3) | module Rails
    type Diff (line 4) | module Diff
      class RailsAppGenerator (line 5) | class RailsAppGenerator
        method initialize (line 6) | def initialize(ref: nil, new_app_options: nil, no_cache: false, lo...
        method clear_cache (line 15) | def clear_cache
        method create_template_app (line 21) | def create_template_app
        method template_app_path (line 27) | def template_app_path
        method install_app_dependencies (line 31) | def install_app_dependencies
        method run_generator (line 40) | def run_generator(generator_name, *args, skip, only)
        method normalize_ref (line 55) | def normalize_ref(ref)
        method ref (line 63) | def ref = @ref ||= rails_repo.latest_commit
        method rails_cache_dir_key (line 65) | def rails_cache_dir_key = "rails-#{ref.first(10)}"
        method railsrc_path (line 67) | def railsrc_path = "#{ENV["HOME"]}/.railsrc"
        method railsrc_options (line 69) | def railsrc_options
        method app_name (line 73) | def app_name = @app_name ||= File.basename(Dir.pwd)
        method cached_app? (line 75) | def cached_app?
        method create_new_rails_app (line 79) | def create_new_rails_app
        method generate_app (line 84) | def generate_app
        method checkout_rails_ref (line 92) | def checkout_rails_ref = rails_repo.checkout(ref)
        method rails_new_options (line 94) | def rails_new_options
        method rails_new_options_hash (line 98) | def rails_new_options_hash

FILE: lib/rails/diff/rails_repo.rb
  type Rails (line 1) | module Rails
    type Diff (line 2) | module Diff
      class RailsRepo (line 3) | class RailsRepo
        method initialize (line 6) | def initialize(logger:, cache_dir: Rails::Diff::CACHE_DIR, rails_r...
        method checkout (line 12) | def checkout(ref)
        method latest_commit (line 22) | def latest_commit
        method up_to_date? (line 29) | def up_to_date?
        method install_dependencies (line 33) | def install_dependencies
        method new_app (line 43) | def new_app(name, options)
        method within (line 51) | def within(dir, &block) = on_rails_dir { Dir.chdir(dir, &block) }
        method rails_path (line 57) | def rails_path
        method on_latest_commit? (line 61) | def on_latest_commit?
        method on_rails_dir (line 70) | def on_rails_dir(&block)
        method current_commit (line 75) | def current_commit = on_rails_dir { `git rev-parse HEAD`.strip }
        method remove_repo (line 77) | def remove_repo = FileUtils.rm_rf(rails_path, secure: true)
        method clone_repo (line 79) | def clone_repo
        method rails_new_command (line 84) | def rails_new_command(name, options)

FILE: lib/rails/diff/shell.rb
  type Rails (line 5) | module Rails
    type Diff (line 6) | module Diff
      type Shell (line 7) | module Shell
        function run! (line 10) | def run!(*cmd, logger:, abort: true)

FILE: lib/rails/diff/version.rb
  type Rails (line 3) | module Rails
    type Diff (line 4) | module Diff

FILE: spec/lib/rails/diff/rails_app_generator_spec.rb
  function build_generator (line 19) | def build_generator(ref: "abc1234567890def", logger: spy, rails_repo: sp...
  function stub_command (line 23) | def stub_command(*args, result: true, **opts)

FILE: spec/lib/rails/diff_spec.rb
  function mock_generator (line 14) | def mock_generator(template_path: template_dir)
  function fake_differ_class (line 18) | def fake_differ_class(output: "some diff output")
  function write_file (line 25) | def write_file(dir, path, content)

FILE: spec/support/git_repo.rb
  class GitRepo (line 4) | class GitRepo
    method initialize (line 8) | def initialize
    method add_commit (line 32) | def add_commit(message)
    method add_tag (line 44) | def add_tag(tag_name)
    method shallow_clone (line 51) | def shallow_clone(dest_path)
    method clone_at_commit (line 55) | def clone_at_commit(commit_sha, dest_path)
    method cleanup (line 63) | def cleanup
Condensed preview — 28 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (55K chars).
[
  {
    "path": ".github/workflows/main.yml",
    "chars": 649,
    "preview": "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:"
  },
  {
    "path": ".gitignore",
    "chars": 113,
    "preview": "/.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",
    "chars": 53,
    "preview": "--format documentation\n--color\n--require spec_helper\n"
  },
  {
    "path": ".ruby-version",
    "chars": 6,
    "preview": "3.4.2\n"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 2758,
    "preview": "## [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"
  },
  {
    "path": "Gemfile",
    "chars": 229,
    "preview": "# frozen_string_literal: true\n\nsource \"https://rubygems.org\"\n\n# Specify your gem's dependencies in rails-diff.gemspec\nge"
  },
  {
    "path": "LICENSE.txt",
    "chars": 1082,
    "preview": "The MIT License (MIT)\n\nCopyright (c) 2025 Matheus Richard\n\nPermission is hereby granted, free of charge, to any person o"
  },
  {
    "path": "README.md",
    "chars": 5072,
    "preview": "# Rails::Diff\n\nCompare your Rails application files with the ones generated by Rails main\nbranch. This helps you keep tr"
  },
  {
    "path": "Rakefile",
    "chars": 163,
    "preview": "# frozen_string_literal: true\n\nrequire \"bundler/gem_tasks\"\nrequire \"rspec/core/rake_task\"\n\nRSpec::Core::RakeTask.new(:sp"
  },
  {
    "path": "bin/console",
    "chars": 284,
    "preview": "#!/usr/bin/env ruby\n# frozen_string_literal: true\n\nrequire \"bundler/setup\"\nrequire \"rails/diff\"\n\n# You can add fixtures "
  },
  {
    "path": "bin/setup",
    "chars": 131,
    "preview": "#!/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 "
  },
  {
    "path": "exe/rails-diff",
    "chars": 112,
    "preview": "#!/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",
    "chars": 3124,
    "preview": "require \"thor\"\n\nmodule Rails\n  module Diff\n    class CLI < Thor\n      class_option :no_cache, type: :boolean, desc: \"Cle"
  },
  {
    "path": "lib/rails/diff/file_tracker.rb",
    "chars": 854,
    "preview": "# frozen_string_literal: true\n\nmodule Rails\n  module Diff\n    module FileTracker\n      DEFAULT_EXCLUSIONS = %w[.git tmp "
  },
  {
    "path": "lib/rails/diff/logger.rb",
    "chars": 372,
    "preview": "module Rails\n  module Diff\n    module Logger\n      extend self\n\n      def info(message)\n        puts \"\\e[1;34minfo:\\e[0m"
  },
  {
    "path": "lib/rails/diff/rails_app_generator.rb",
    "chars": 3028,
    "preview": "require \"digest\"\n\nmodule Rails\n  module Diff\n    class RailsAppGenerator\n      def initialize(ref: nil, new_app_options:"
  },
  {
    "path": "lib/rails/diff/rails_repo.rb",
    "chars": 2561,
    "preview": "module Rails\n  module Diff\n    class RailsRepo\n      RAILS_REPO = \"https://github.com/rails/rails.git\"\n\n      def initia"
  },
  {
    "path": "lib/rails/diff/shell.rb",
    "chars": 450,
    "preview": "# frozen_string_literal: true\n\nrequire \"open3\"\n\nmodule Rails\n  module Diff\n    module Shell\n      extend self\n\n      def"
  },
  {
    "path": "lib/rails/diff/version.rb",
    "chars": 90,
    "preview": "# frozen_string_literal: true\n\nmodule Rails\n  module Diff\n    VERSION = \"0.8.0\"\n  end\nend\n"
  },
  {
    "path": "lib/rails/diff.rb",
    "chars": 2804,
    "preview": "# frozen_string_literal: true\n\nrequire \"rails\"\nrequire \"difftastic\"\nrequire \"fileutils\"\nrequire_relative \"diff/cli\"\nrequ"
  },
  {
    "path": "rails-diff.gemspec",
    "chars": 1804,
    "preview": "# frozen_string_literal: true\n\nrequire_relative \"lib/rails/diff/version\"\n\nGem::Specification.new do |spec|\n  spec.name ="
  },
  {
    "path": "spec/integration/rails/diff/file_tracker_spec.rb",
    "chars": 2207,
    "preview": "# frozen_string_literal: true\n\nrequire \"rails/diff/file_tracker\"\n\nRSpec.describe Rails::Diff::FileTracker do\n  let(:temp"
  },
  {
    "path": "spec/lib/rails/diff/cli_spec.rb",
    "chars": 1681,
    "preview": "# frozen_string_literal: true\n\n# require \"spec_helper\"\n\nRSpec.describe Rails::Diff::CLI do\n  describe \"#file\" do\n    con"
  },
  {
    "path": "spec/lib/rails/diff/rails_app_generator_spec.rb",
    "chars": 7745,
    "preview": "# frozen_string_literal: true\n\nrequire \"rails/diff/rails_app_generator\"\n\nRSpec.describe Rails::Diff::RailsAppGenerator d"
  },
  {
    "path": "spec/lib/rails/diff/rails_repo_spec.rb",
    "chars": 5786,
    "preview": "# Helper indices for commits:\n#   @git_repo.commits[0] => 'add railties dir' (initial commit)\n#   @git_repo.commits[1] ="
  },
  {
    "path": "spec/lib/rails/diff_spec.rb",
    "chars": 5402,
    "preview": "# frozen_string_literal: true\n\nRSpec.describe Rails::Diff do\n  let(:template_dir) { Dir.mktmpdir }\n  let(:repo_dir) { Di"
  },
  {
    "path": "spec/spec_helper.rb",
    "chars": 523,
    "preview": "# frozen_string_literal: true\n\nrequire \"simplecov\"\nSimpleCov.start do\n  enable_coverage :branch\n  add_filter \"/spec/\"\nen"
  },
  {
    "path": "spec/support/git_repo.rb",
    "chars": 1998,
    "preview": "require \"fileutils\"\nrequire \"tmpdir\"\n\nclass GitRepo\n  attr_reader :remote_repo, :commits\n\n  # Initializes and creates a "
  }
]

About this extraction

This page contains the full source code of the MatheusRich/rails-diff GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 28 files (49.9 KB), approximately 13.8k tokens, and a symbol index with 83 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!