diff --git a/CHANGELOG.md b/CHANGELOG.md index ba134e7..05e7445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [Unreleased] + +- `git pkgs diff-driver` command for semantic lockfile diffs in `git diff` + ## [0.3.0] - 2026-01-03 - Pager support for long output (respects `GIT_PAGER`, `core.pager`, `PAGER`) diff --git a/README.md b/README.md index 77c42d1..08f96e4 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,30 @@ jobs: - run: git pkgs diff --from=origin/${{ github.base_ref }} --to=HEAD ``` +### Diff driver + +Install a git textconv driver that shows semantic dependency changes instead of raw lockfile diffs: + +```bash +git pkgs diff-driver --install +``` + +Now `git diff` on lockfiles shows a sorted dependency list instead of raw lockfile changes: + +```diff +diff --git a/Gemfile.lock b/Gemfile.lock +--- a/Gemfile.lock ++++ b/Gemfile.lock +@@ -1,3 +1,3 @@ ++kamal 1.0.0 +-puma 5.0.0 ++puma 6.0.0 + rails 7.0.0 +-sidekiq 6.0.0 +``` + +Use `git diff --no-textconv` to see the raw lockfile diff. To remove: `git pkgs diff-driver --uninstall` + ## Configuration git-pkgs respects [standard git configuration](https://git-scm.com/docs/git-config). diff --git a/lib/git/pkgs.rb b/lib/git/pkgs.rb index 2ec34dd..06b3e32 100644 --- a/lib/git/pkgs.rb +++ b/lib/git/pkgs.rb @@ -33,6 +33,7 @@ require_relative "pkgs/commands/log" require_relative "pkgs/commands/upgrade" require_relative "pkgs/commands/schema" +require_relative "pkgs/commands/diff_driver" module Git module Pkgs diff --git a/lib/git/pkgs/cli.rb b/lib/git/pkgs/cli.rb index 23c64f4..f0d09aa 100644 --- a/lib/git/pkgs/cli.rb +++ b/lib/git/pkgs/cli.rb @@ -5,7 +5,7 @@ module Git module Pkgs class CLI - COMMANDS = %w[init update hooks info list tree history search why blame stale stats diff branch show log upgrade schema].freeze + COMMANDS = %w[init update hooks info list tree history search why blame stale stats diff branch show log upgrade schema diff-driver].freeze ALIASES = { "praise" => "blame", "outdated" => "stale" }.freeze def self.run(args) @@ -36,7 +36,9 @@ def run def run_command(command) command = ALIASES.fetch(command, command) - command_class = Commands.const_get(command.capitalize.gsub(/_([a-z])/) { $1.upcase }) + # Convert kebab-case or snake_case to PascalCase + class_name = command.split(/[-_]/).map(&:capitalize).join + command_class = Commands.const_get(class_name) command_class.new(@args).run rescue NameError $stderr.puts "Command '#{command}' not yet implemented" diff --git a/lib/git/pkgs/commands/diff_driver.rb b/lib/git/pkgs/commands/diff_driver.rb new file mode 100644 index 0000000..bab189d --- /dev/null +++ b/lib/git/pkgs/commands/diff_driver.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require "bibliothecary" + +module Git + module Pkgs + module Commands + class DiffDriver + include Output + + # Only lockfiles - manifests are human-readable and diff fine normally + LOCKFILE_PATTERNS = %w[ + Brewfile.lock.json + Cargo.lock + Cartfile.resolved + Gemfile.lock + Gopkg.lock + Package.resolved + Pipfile.lock + Podfile.lock + Project.lock.json + bun.lock + composer.lock + gems.locked + glide.lock + go.sum + mix.lock + npm-shrinkwrap.json + package-lock.json + packages.lock.json + paket.lock + pnpm-lock.yaml + poetry.lock + project.assets.json + pubspec.lock + pylock.toml + shard.lock + uv.lock + yarn.lock + ].freeze + + def initialize(args) + @args = args + @options = parse_options + end + + def run + if @options[:install] + install_driver + return + end + + if @options[:uninstall] + uninstall_driver + return + end + + # textconv mode: single file argument, output dependency list + if @args.length == 1 + output_textconv(@args[0]) + return + end + + error "Usage: git pkgs diff-driver " + end + + def output_textconv(file_path) + content = read_file(file_path) + deps = parse_deps(file_path, content) + + # Output sorted dependency list for git to diff + deps.keys.sort.each do |name| + dep = deps[name] + # Only show type if it's not runtime (the default) + type_suffix = dep[:type] && dep[:type] != "runtime" ? " [#{dep[:type]}]" : "" + puts "#{name} #{dep[:requirement]}#{type_suffix}" + end + end + + def install_driver + # Set up git config for textconv + system("git", "config", "diff.pkgs.textconv", "git-pkgs diff-driver") + + # Add to .gitattributes + gitattributes_path = File.join(Dir.pwd, ".gitattributes") + existing = File.exist?(gitattributes_path) ? File.read(gitattributes_path) : "" + + new_entries = [] + LOCKFILE_PATTERNS.each do |pattern| + entry = "#{pattern} diff=pkgs" + new_entries << entry unless existing.include?(entry) + end + + if new_entries.any? + File.open(gitattributes_path, "a") do |f| + f.puts unless existing.end_with?("\n") || existing.empty? + f.puts "# git-pkgs textconv for lockfiles" + new_entries.each { |entry| f.puts entry } + end + end + + puts "Installed textconv driver for lockfiles." + puts " git config: diff.pkgs.textconv = git-pkgs diff-driver" + puts " .gitattributes: #{new_entries.count} lockfile patterns added" + puts + puts "Now 'git diff' on lockfiles shows dependency changes." + puts "Use 'git diff --no-textconv' to see raw diff." + end + + def uninstall_driver + system("git", "config", "--unset", "diff.pkgs.textconv") + + gitattributes_path = File.join(Dir.pwd, ".gitattributes") + if File.exist?(gitattributes_path) + lines = File.readlines(gitattributes_path) + lines.reject! { |line| line.include?("diff=pkgs") || line.include?("# git-pkgs") } + File.write(gitattributes_path, lines.join) + end + + puts "Uninstalled diff driver." + end + + def read_file(path) + return "" if path == "/dev/null" + return "" unless File.exist?(path) + + File.read(path) + end + + def parse_deps(path, content) + return {} if content.empty? + + result = Bibliothecary.analyse_file(path, content).first + return {} unless result + + result[:dependencies].map { |d| [d[:name], d] }.to_h + rescue StandardError + {} + end + + def parse_options + options = {} + + parser = OptionParser.new do |opts| + opts.banner = "Usage: git pkgs diff-driver " + opts.separator "" + opts.separator "Outputs dependency list for git textconv diffing." + + opts.on("--install", "Install textconv driver for lockfiles") do + options[:install] = true + end + + opts.on("--uninstall", "Uninstall textconv driver") do + options[:uninstall] = true + end + + opts.on("-h", "--help", "Show this help") do + puts opts + exit + end + end + + parser.parse!(@args) + options + end + end + end + end +end diff --git a/test/git/pkgs/test_diff_driver.rb b/test/git/pkgs/test_diff_driver.rb new file mode 100644 index 0000000..8a6c247 --- /dev/null +++ b/test/git/pkgs/test_diff_driver.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +require "test_helper" +require "tempfile" + +class Git::Pkgs::TestDiffDriver < Minitest::Test + def test_outputs_sorted_dependency_list + content = <<~GEMFILE_LOCK + GEM + remote: https://rubygems.org/ + specs: + rails (7.0.0) + puma (5.0.0) + sidekiq (6.0.0) + + DEPENDENCIES + rails + puma + sidekiq + GEMFILE_LOCK + + output = run_textconv(content, "Gemfile.lock") + + lines = output.strip.split("\n") + assert_equal 3, lines.count + assert_equal "puma 5.0.0", lines[0] + assert_equal "rails 7.0.0", lines[1] + assert_equal "sidekiq 6.0.0", lines[2] + end + + def test_handles_empty_file + output = run_textconv("", "Gemfile.lock") + assert_empty output.strip + end + + def test_handles_package_lock_json + content = <<~JSON + { + "name": "test", + "lockfileVersion": 2, + "packages": { + "": { + "dependencies": { + "react": "^18.0.0", + "lodash": "^4.0.0" + } + }, + "node_modules/react": { + "version": "18.2.0" + }, + "node_modules/lodash": { + "version": "4.17.21" + } + } + } + JSON + + output = run_textconv(content, "package-lock.json") + + # Should have dependencies listed + refute_empty output.strip + end + + def test_handles_invalid_content + output = run_textconv("not a valid lockfile", "Gemfile.lock") + assert_empty output.strip + end + + def test_shows_type_for_non_runtime_dependencies + content = <<~GEMFILE_LOCK + GEM + remote: https://rubygems.org/ + specs: + rails (7.0.0) + rspec (3.0.0) + + DEPENDENCIES + rails + rspec + + PLATFORMS + ruby + + BUNDLED WITH + 2.4.0 + GEMFILE_LOCK + + output = run_textconv(content, "Gemfile.lock") + + lines = output.strip.split("\n") + assert_equal 3, lines.count + # Bundler is extracted from BUNDLED WITH section + assert_equal "bundler 2.4.0", lines[0] + assert_equal "rails 7.0.0", lines[1] + assert_equal "rspec 3.0.0", lines[2] + end + + def run_textconv(content, filename) + # Create temp directory with properly named file so Bibliothecary can identify it + dir = Dir.mktmpdir + file_path = File.join(dir, filename) + + begin + File.write(file_path, content) + + output = StringIO.new + original_stdout = $stdout + $stdout = output + + begin + # Simulate how git calls textconv - with just the file path + driver = Git::Pkgs::Commands::DiffDriver.new([file_path]) + driver.run + ensure + $stdout = original_stdout + end + + output.string + ensure + FileUtils.rm_rf(dir) + end + end +end + +class Git::Pkgs::TestDiffDriverInstall < Minitest::Test + include TestHelpers + + def setup + create_test_repo + end + + def teardown + cleanup_test_repo + end + + def test_install_creates_gitattributes_for_lockfiles + Dir.chdir(@test_dir) do + capture_stdout do + driver = Git::Pkgs::Commands::DiffDriver.new(["--install"]) + driver.run + end + + assert File.exist?(".gitattributes") + content = File.read(".gitattributes") + assert_includes content, "Gemfile.lock diff=pkgs" + assert_includes content, "package-lock.json diff=pkgs" + assert_includes content, "yarn.lock diff=pkgs" + # Should NOT include manifests + refute_includes content, "Gemfile diff=pkgs" + refute_includes content, "package.json diff=pkgs" + end + end + + def test_install_sets_textconv_config + Dir.chdir(@test_dir) do + capture_stdout do + driver = Git::Pkgs::Commands::DiffDriver.new(["--install"]) + driver.run + end + + config = `git config --get diff.pkgs.textconv`.chomp + assert_equal "git-pkgs diff-driver", config + end + end + + def test_uninstall_removes_config + Dir.chdir(@test_dir) do + # First install + capture_stdout do + Git::Pkgs::Commands::DiffDriver.new(["--install"]).run + end + + # Then uninstall + capture_stdout do + Git::Pkgs::Commands::DiffDriver.new(["--uninstall"]).run + end + + config = `git config --get diff.pkgs.textconv 2>&1`.chomp + refute_equal "git-pkgs diff-driver", config + end + end + + def test_uninstall_cleans_gitattributes + Dir.chdir(@test_dir) do + # First install + capture_stdout do + Git::Pkgs::Commands::DiffDriver.new(["--install"]).run + end + + # Then uninstall + capture_stdout do + Git::Pkgs::Commands::DiffDriver.new(["--uninstall"]).run + end + + content = File.read(".gitattributes") + refute_includes content, "diff=pkgs" + end + end + + def capture_stdout + original = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original + end +end