git-pkgs 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md CHANGED
@@ -258,6 +258,23 @@ git pkgs show HEAD~5 # relative ref
258
258
 
259
259
  Like `git show` but for dependencies. Shows what was added, modified, or removed in a single commit.
260
260
 
261
+ ### Find where a package is declared
262
+
263
+ ```bash
264
+ git pkgs where rails # find in manifest files
265
+ git pkgs where lodash -C 2 # show 2 lines of context
266
+ git pkgs where express --ecosystem=npm
267
+ ```
268
+
269
+ Shows which manifest files declare a package and the exact line:
270
+
271
+ ```
272
+ Gemfile:5:gem "rails", "~> 7.0"
273
+ Gemfile.lock:142: rails (7.0.8)
274
+ ```
275
+
276
+ Like `grep` but scoped to manifest files that git-pkgs knows about.
277
+
261
278
  ### List commits with dependency changes
262
279
 
263
280
  ```bash
@@ -328,6 +345,30 @@ jobs:
328
345
  - run: git pkgs diff --from=origin/${{ github.base_ref }} --to=HEAD
329
346
  ```
330
347
 
348
+ ### Diff driver
349
+
350
+ Install a git textconv driver that shows semantic dependency changes instead of raw lockfile diffs:
351
+
352
+ ```bash
353
+ git pkgs diff-driver --install
354
+ ```
355
+
356
+ Now `git diff` on lockfiles shows a sorted dependency list instead of raw lockfile changes:
357
+
358
+ ```diff
359
+ diff --git a/Gemfile.lock b/Gemfile.lock
360
+ --- a/Gemfile.lock
361
+ +++ b/Gemfile.lock
362
+ @@ -1,3 +1,3 @@
363
+ +kamal 1.0.0
364
+ -puma 5.0.0
365
+ +puma 6.0.0
366
+ rails 7.0.0
367
+ -sidekiq 6.0.0
368
+ ```
369
+
370
+ Use `git diff --no-textconv` to see the raw lockfile diff. To remove: `git pkgs diff-driver --uninstall`
371
+
331
372
  ## Configuration
332
373
 
333
374
  git-pkgs respects [standard git configuration](https://git-scm.com/docs/git-config).
data/SECURITY.md ADDED
@@ -0,0 +1,7 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ To report a security vulnerability, please email andrewnez@gmail.com.
6
+
7
+ Do not open a public issue for security vulnerabilities.
@@ -46,7 +46,7 @@ module Git
46
46
  QUICK_MANIFEST_REGEX = Regexp.union(
47
47
  QUICK_MANIFEST_PATTERNS.map do |pattern|
48
48
  if pattern.include?('*')
49
- Regexp.new(pattern.gsub('.', '\\.').gsub('*', '.*'))
49
+ Regexp.new(Regexp.escape(pattern).gsub('\\*', '.*'))
50
50
  else
51
51
  /(?:^|\/)#{Regexp.escape(pattern)}$/
52
52
  end
data/lib/git/pkgs/cli.rb CHANGED
@@ -5,7 +5,7 @@ require "optparse"
5
5
  module Git
6
6
  module Pkgs
7
7
  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 where why blame stale stats diff branch show log upgrade schema diff-driver].freeze
9
9
  ALIASES = { "praise" => "blame", "outdated" => "stale" }.freeze
10
10
 
11
11
  def self.run(args)
@@ -36,7 +36,9 @@ module Git
36
36
 
37
37
  def run_command(command)
38
38
  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)
40
42
  command_class.new(@args).run
41
43
  rescue NameError
42
44
  $stderr.puts "Command '#{command}' not yet implemented"
@@ -57,6 +59,7 @@ module Git
57
59
  tree Show dependency tree grouped by type
58
60
  history Show the history of a package
59
61
  search Find a dependency across all history
62
+ where Show where a package appears in manifest files
60
63
  why Explain why a dependency exists
61
64
  blame Show who added each dependency
62
65
  stale Show dependencies that haven't been updated
@@ -74,6 +74,7 @@ module Git
74
74
  def self.green(text) = colorize(text, :green)
75
75
  def self.yellow(text) = colorize(text, :yellow)
76
76
  def self.blue(text) = colorize(text, :blue)
77
+ def self.magenta(text) = colorize(text, :magenta)
77
78
  def self.cyan(text) = colorize(text, :cyan)
78
79
  def self.bold(text) = colorize(text, :bold)
79
80
  def self.dim(text) = colorize(text, :dim)
@@ -101,7 +101,7 @@ module Git
101
101
  def parse_coauthors(message)
102
102
  return [] unless message
103
103
 
104
- message.scan(/^Co-authored-by:\s*(.+?)\s*<[^>]+>/i).flatten
104
+ message.scan(/^Co-authored-by:([^<]+)<[^>]+>/i).flatten.map(&:strip)
105
105
  end
106
106
 
107
107
  def bot_author?(name)
@@ -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
@@ -69,7 +69,11 @@ module Git
69
69
  puts " Commits with snapshots: #{snapshot_commits}"
70
70
  if total_dep_commits > 0
71
71
  ratio = (snapshot_commits.to_f / total_dep_commits * 100).round(1)
72
- puts " Coverage: #{ratio}% (1 snapshot per ~#{(total_dep_commits.to_f / snapshot_commits).round(0)} changes)"
72
+ if snapshot_commits > 0
73
+ puts " Coverage: #{ratio}% (1 snapshot per ~#{(total_dep_commits / snapshot_commits)} changes)"
74
+ else
75
+ puts " Coverage: #{ratio}%"
76
+ end
73
77
  end
74
78
  end
75
79
 
@@ -17,6 +17,9 @@ module Git
17
17
  def run
18
18
  repo = Repository.new
19
19
 
20
+ branch_name = @options[:branch] || repo.default_branch
21
+ error "Branch '#{branch_name}' not found" unless repo.branch_exists?(branch_name)
22
+
20
23
  if Database.exists?(repo.git_dir) && !@options[:force]
21
24
  puts "Database already exists. Use --force to rebuild."
22
25
  return
@@ -27,9 +30,6 @@ module Git
27
30
  Database.create_schema(with_indexes: false)
28
31
  Database.optimize_for_bulk_writes
29
32
 
30
- branch_name = @options[:branch] || repo.default_branch
31
- error "Branch '#{branch_name}' not found" unless repo.branch_exists?(branch_name)
32
-
33
33
  branch = Models::Branch.find_or_create(branch_name)
34
34
  analyzer = Analyzer.new(repo)
35
35
 
@@ -73,6 +73,7 @@ module Git
73
73
  dependency_commit_count = 0
74
74
  snapshots_stored = 0
75
75
  processed = 0
76
+ last_processed_sha = nil
76
77
 
77
78
  flush = lambda do
78
79
  return if pending_commits.empty?
@@ -160,6 +161,8 @@ module Git
160
161
  position: processed
161
162
  }
162
163
 
164
+ last_processed_sha = rugged_commit.oid
165
+
163
166
  if has_changes
164
167
  dependency_commit_count += 1
165
168
 
@@ -206,13 +209,12 @@ module Git
206
209
  flush.call if pending_commits.size >= BATCH_SIZE
207
210
  end
208
211
 
209
- # Always store final snapshot for HEAD
210
- if snapshot.any?
211
- last_sha = commits.last&.oid
212
- if last_sha && !pending_snapshots.any? { |s| s[:sha] == last_sha }
212
+ # Always store final snapshot for the last processed commit
213
+ if snapshot.any? && last_processed_sha
214
+ unless pending_snapshots.any? { |s| s[:sha] == last_processed_sha }
213
215
  snapshot.each do |(manifest_path, name), dep_info|
214
216
  pending_snapshots << {
215
- sha: last_sha,
217
+ sha: last_processed_sha,
216
218
  manifest_path: manifest_path,
217
219
  name: name,
218
220
  ecosystem: dep_info[:ecosystem],
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class Where
7
+ include Output
8
+
9
+ def initialize(args)
10
+ @args = args
11
+ @options = parse_options
12
+ end
13
+
14
+ def run
15
+ name = @args.first
16
+
17
+ error "Usage: git pkgs where <package-name>" unless name
18
+
19
+ repo = Repository.new
20
+ require_database(repo)
21
+
22
+ Database.connect(repo.git_dir)
23
+
24
+ workdir = File.dirname(repo.git_dir)
25
+ branch = Models::Branch.find_by(name: @options[:branch] || repo.default_branch)
26
+
27
+ unless branch
28
+ error "Branch not found. Run 'git pkgs init' first."
29
+ end
30
+
31
+ snapshots = Models::DependencySnapshot.current_for_branch(branch)
32
+ snapshots = snapshots.where(ecosystem: @options[:ecosystem]) if @options[:ecosystem]
33
+
34
+ manifest_paths = snapshots.for_package(name).joins(:manifest).pluck("manifests.path").uniq
35
+
36
+ if manifest_paths.empty?
37
+ empty_result "Package '#{name}' not found in current dependencies"
38
+ return
39
+ end
40
+
41
+ results = manifest_paths.flat_map do |path|
42
+ find_in_manifest(name, File.join(workdir, path), path)
43
+ end
44
+
45
+ if results.empty?
46
+ empty_result "Package '#{name}' tracked but not found in current files"
47
+ return
48
+ end
49
+
50
+ if @options[:format] == "json"
51
+ output_json(results)
52
+ else
53
+ paginate { output_text(results, name) }
54
+ end
55
+ end
56
+
57
+ def find_in_manifest(name, full_path, display_path)
58
+ return [] unless File.exist?(full_path)
59
+
60
+ lines = File.readlines(full_path)
61
+ matches = []
62
+
63
+ lines.each_with_index do |line, idx|
64
+ next unless line.include?(name)
65
+
66
+ match = { path: display_path, line: idx + 1, content: line.rstrip }
67
+
68
+ if context_lines > 0
69
+ match[:before] = context_before(lines, idx)
70
+ match[:after] = context_after(lines, idx)
71
+ end
72
+
73
+ matches << match
74
+ end
75
+
76
+ matches
77
+ end
78
+
79
+ def context_lines
80
+ @options[:context] || 0
81
+ end
82
+
83
+ def context_before(lines, idx)
84
+ start_idx = [0, idx - context_lines].max
85
+ (start_idx...idx).map { |i| { line: i + 1, content: lines[i].rstrip } }
86
+ end
87
+
88
+ def context_after(lines, idx)
89
+ end_idx = [lines.length - 1, idx + context_lines].min
90
+ ((idx + 1)..end_idx).map { |i| { line: i + 1, content: lines[i].rstrip } }
91
+ end
92
+
93
+ def output_text(results, name)
94
+ results.each_with_index do |result, i|
95
+ puts "--" if i > 0 && context_lines > 0
96
+
97
+ result[:before]&.each do |ctx|
98
+ puts format_context_line(result[:path], ctx[:line], ctx[:content])
99
+ end
100
+
101
+ puts format_match_line(result[:path], result[:line], result[:content], name)
102
+
103
+ result[:after]&.each do |ctx|
104
+ puts format_context_line(result[:path], ctx[:line], ctx[:content])
105
+ end
106
+ end
107
+ end
108
+
109
+ def format_match_line(path, line_num, content, name)
110
+ path_str = Color.magenta(path)
111
+ line_str = Color.green(line_num.to_s)
112
+ highlighted = content.gsub(name, Color.red(name))
113
+ "#{path_str}:#{line_str}:#{highlighted}"
114
+ end
115
+
116
+ def format_context_line(path, line_num, content)
117
+ path_str = Color.magenta(path)
118
+ line_str = Color.green(line_num.to_s)
119
+ content_str = Color.dim(content)
120
+ "#{path_str}-#{line_str}-#{content_str}"
121
+ end
122
+
123
+ def output_json(results)
124
+ require "json"
125
+ puts JSON.pretty_generate(results)
126
+ end
127
+
128
+ def parse_options
129
+ options = {}
130
+
131
+ parser = OptionParser.new do |opts|
132
+ opts.banner = "Usage: git pkgs where <package-name> [options]"
133
+
134
+ opts.on("-b", "--branch=NAME", "Branch to search (default: current)") do |v|
135
+ options[:branch] = v
136
+ end
137
+
138
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
139
+ options[:ecosystem] = v
140
+ end
141
+
142
+ opts.on("-C", "--context=NUM", Integer, "Show NUM lines of context") do |v|
143
+ options[:context] = v
144
+ end
145
+
146
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
147
+ options[:format] = v
148
+ end
149
+
150
+ opts.on("--no-pager", "Do not pipe output into a pager") do
151
+ options[:no_pager] = true
152
+ end
153
+
154
+ opts.on("-h", "--help", "Show this help") do
155
+ puts opts
156
+ exit
157
+ end
158
+ end
159
+
160
+ parser.parse!(@args)
161
+ options
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Git
4
4
  module Pkgs
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
data/lib/git/pkgs.rb CHANGED
@@ -30,9 +30,11 @@ require_relative "pkgs/commands/tree"
30
30
  require_relative "pkgs/commands/branch"
31
31
  require_relative "pkgs/commands/search"
32
32
  require_relative "pkgs/commands/show"
33
+ require_relative "pkgs/commands/where"
33
34
  require_relative "pkgs/commands/log"
34
35
  require_relative "pkgs/commands/upgrade"
35
36
  require_relative "pkgs/commands/schema"
37
+ require_relative "pkgs/commands/diff_driver"
36
38
 
37
39
  module Git
38
40
  module Pkgs
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: git-pkgs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Nesbitt
@@ -76,9 +76,11 @@ extra_rdoc_files: []
76
76
  files:
77
77
  - CHANGELOG.md
78
78
  - CODE_OF_CONDUCT.md
79
+ - CONTRIBUTING.md
79
80
  - LICENSE
80
81
  - README.md
81
82
  - Rakefile
83
+ - SECURITY.md
82
84
  - benchmark_bulk.rb
83
85
  - benchmark_db.rb
84
86
  - benchmark_detailed.rb
@@ -92,6 +94,7 @@ files:
92
94
  - lib/git/pkgs/commands/blame.rb
93
95
  - lib/git/pkgs/commands/branch.rb
94
96
  - lib/git/pkgs/commands/diff.rb
97
+ - lib/git/pkgs/commands/diff_driver.rb
95
98
  - lib/git/pkgs/commands/history.rb
96
99
  - lib/git/pkgs/commands/hooks.rb
97
100
  - lib/git/pkgs/commands/info.rb
@@ -106,6 +109,7 @@ files:
106
109
  - lib/git/pkgs/commands/tree.rb
107
110
  - lib/git/pkgs/commands/update.rb
108
111
  - lib/git/pkgs/commands/upgrade.rb
112
+ - lib/git/pkgs/commands/where.rb
109
113
  - lib/git/pkgs/commands/why.rb
110
114
  - lib/git/pkgs/database.rb
111
115
  - lib/git/pkgs/models/branch.rb
@@ -125,6 +129,7 @@ metadata:
125
129
  homepage_uri: https://github.com/andrew/git-pkgs
126
130
  source_code_uri: https://github.com/andrew/git-pkgs
127
131
  changelog_uri: https://github.com/andrew/git-pkgs/blob/main/CHANGELOG.md
132
+ funding_uri: https://github.com/sponsors/andrew
128
133
  rdoc_options: []
129
134
  require_paths:
130
135
  - lib