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
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
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.