Skip to content
This repository was archived by the owner on Jan 22, 2026. It is now read-only.

Commit 59d5604

Browse files
committed
Add diff-driver command for semantic lockfile diffs and related tests
1 parent 7f9132e commit 59d5604

File tree

6 files changed

+410
-2
lines changed

6 files changed

+410
-2
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## [Unreleased]
2+
3+
- `git pkgs diff-driver` command for semantic lockfile diffs in `git diff`
4+
15
## [0.3.0] - 2026-01-03
26

37
- Pager support for long output (respects `GIT_PAGER`, `core.pager`, `PAGER`)

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,30 @@ jobs:
328328
- run: git pkgs diff --from=origin/${{ github.base_ref }} --to=HEAD
329329
```
330330
331+
### Diff driver
332+
333+
Install a git textconv driver that shows semantic dependency changes instead of raw lockfile diffs:
334+
335+
```bash
336+
git pkgs diff-driver --install
337+
```
338+
339+
Now `git diff` on lockfiles shows a sorted dependency list instead of raw lockfile changes:
340+
341+
```diff
342+
diff --git a/Gemfile.lock b/Gemfile.lock
343+
--- a/Gemfile.lock
344+
+++ b/Gemfile.lock
345+
@@ -1,3 +1,3 @@
346+
+kamal 1.0.0
347+
-puma 5.0.0
348+
+puma 6.0.0
349+
rails 7.0.0
350+
-sidekiq 6.0.0
351+
```
352+
353+
Use `git diff --no-textconv` to see the raw lockfile diff. To remove: `git pkgs diff-driver --uninstall`
354+
331355
## Configuration
332356

333357
git-pkgs respects [standard git configuration](https://git-scm.com/docs/git-config).

lib/git/pkgs.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
require_relative "pkgs/commands/log"
3434
require_relative "pkgs/commands/upgrade"
3535
require_relative "pkgs/commands/schema"
36+
require_relative "pkgs/commands/diff_driver"
3637

3738
module Git
3839
module Pkgs

lib/git/pkgs/cli.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
module Git
66
module Pkgs
77
class CLI
8-
COMMANDS = %w[init update hooks info list tree history search why blame stale stats diff branch show log upgrade schema].freeze
8+
COMMANDS = %w[init update hooks info list tree history search why blame stale stats diff branch show log upgrade schema diff-driver].freeze
99
ALIASES = { "praise" => "blame", "outdated" => "stale" }.freeze
1010

1111
def self.run(args)
@@ -36,7 +36,9 @@ def run
3636

3737
def run_command(command)
3838
command = ALIASES.fetch(command, command)
39-
command_class = Commands.const_get(command.capitalize.gsub(/_([a-z])/) { $1.upcase })
39+
# Convert kebab-case or snake_case to PascalCase
40+
class_name = command.split(/[-_]/).map(&:capitalize).join
41+
command_class = Commands.const_get(class_name)
4042
command_class.new(@args).run
4143
rescue NameError
4244
$stderr.puts "Command '#{command}' not yet implemented"
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# frozen_string_literal: true
2+
3+
require "bibliothecary"
4+
5+
module Git
6+
module Pkgs
7+
module Commands
8+
class DiffDriver
9+
include Output
10+
11+
# Only lockfiles - manifests are human-readable and diff fine normally
12+
LOCKFILE_PATTERNS = %w[
13+
Brewfile.lock.json
14+
Cargo.lock
15+
Cartfile.resolved
16+
Gemfile.lock
17+
Gopkg.lock
18+
Package.resolved
19+
Pipfile.lock
20+
Podfile.lock
21+
Project.lock.json
22+
bun.lock
23+
composer.lock
24+
gems.locked
25+
glide.lock
26+
go.sum
27+
mix.lock
28+
npm-shrinkwrap.json
29+
package-lock.json
30+
packages.lock.json
31+
paket.lock
32+
pnpm-lock.yaml
33+
poetry.lock
34+
project.assets.json
35+
pubspec.lock
36+
pylock.toml
37+
shard.lock
38+
uv.lock
39+
yarn.lock
40+
].freeze
41+
42+
def initialize(args)
43+
@args = args
44+
@options = parse_options
45+
end
46+
47+
def run
48+
if @options[:install]
49+
install_driver
50+
return
51+
end
52+
53+
if @options[:uninstall]
54+
uninstall_driver
55+
return
56+
end
57+
58+
# textconv mode: single file argument, output dependency list
59+
if @args.length == 1
60+
output_textconv(@args[0])
61+
return
62+
end
63+
64+
error "Usage: git pkgs diff-driver <file>"
65+
end
66+
67+
def output_textconv(file_path)
68+
content = read_file(file_path)
69+
deps = parse_deps(file_path, content)
70+
71+
# Output sorted dependency list for git to diff
72+
deps.keys.sort.each do |name|
73+
dep = deps[name]
74+
# Only show type if it's not runtime (the default)
75+
type_suffix = dep[:type] && dep[:type] != "runtime" ? " [#{dep[:type]}]" : ""
76+
puts "#{name} #{dep[:requirement]}#{type_suffix}"
77+
end
78+
end
79+
80+
def install_driver
81+
# Set up git config for textconv
82+
system("git", "config", "diff.pkgs.textconv", "git-pkgs diff-driver")
83+
84+
# Add to .gitattributes
85+
gitattributes_path = File.join(Dir.pwd, ".gitattributes")
86+
existing = File.exist?(gitattributes_path) ? File.read(gitattributes_path) : ""
87+
88+
new_entries = []
89+
LOCKFILE_PATTERNS.each do |pattern|
90+
entry = "#{pattern} diff=pkgs"
91+
new_entries << entry unless existing.include?(entry)
92+
end
93+
94+
if new_entries.any?
95+
File.open(gitattributes_path, "a") do |f|
96+
f.puts unless existing.end_with?("\n") || existing.empty?
97+
f.puts "# git-pkgs textconv for lockfiles"
98+
new_entries.each { |entry| f.puts entry }
99+
end
100+
end
101+
102+
puts "Installed textconv driver for lockfiles."
103+
puts " git config: diff.pkgs.textconv = git-pkgs diff-driver"
104+
puts " .gitattributes: #{new_entries.count} lockfile patterns added"
105+
puts
106+
puts "Now 'git diff' on lockfiles shows dependency changes."
107+
puts "Use 'git diff --no-textconv' to see raw diff."
108+
end
109+
110+
def uninstall_driver
111+
system("git", "config", "--unset", "diff.pkgs.textconv")
112+
113+
gitattributes_path = File.join(Dir.pwd, ".gitattributes")
114+
if File.exist?(gitattributes_path)
115+
lines = File.readlines(gitattributes_path)
116+
lines.reject! { |line| line.include?("diff=pkgs") || line.include?("# git-pkgs") }
117+
File.write(gitattributes_path, lines.join)
118+
end
119+
120+
puts "Uninstalled diff driver."
121+
end
122+
123+
def read_file(path)
124+
return "" if path == "/dev/null"
125+
return "" unless File.exist?(path)
126+
127+
File.read(path)
128+
end
129+
130+
def parse_deps(path, content)
131+
return {} if content.empty?
132+
133+
result = Bibliothecary.analyse_file(path, content).first
134+
return {} unless result
135+
136+
result[:dependencies].map { |d| [d[:name], d] }.to_h
137+
rescue StandardError
138+
{}
139+
end
140+
141+
def parse_options
142+
options = {}
143+
144+
parser = OptionParser.new do |opts|
145+
opts.banner = "Usage: git pkgs diff-driver <file>"
146+
opts.separator ""
147+
opts.separator "Outputs dependency list for git textconv diffing."
148+
149+
opts.on("--install", "Install textconv driver for lockfiles") do
150+
options[:install] = true
151+
end
152+
153+
opts.on("--uninstall", "Uninstall textconv driver") do
154+
options[:uninstall] = true
155+
end
156+
157+
opts.on("-h", "--help", "Show this help") do
158+
puts opts
159+
exit
160+
end
161+
end
162+
163+
parser.parse!(@args)
164+
options
165+
end
166+
end
167+
end
168+
end
169+
end

0 commit comments

Comments
 (0)