git-pkgs 0.6.2 → 0.8.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +28 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +25 -0
  5. data/Dockerfile +18 -0
  6. data/Formula/git-pkgs.rb +28 -0
  7. data/README.md +90 -6
  8. data/lib/git/pkgs/analyzer.rb +142 -10
  9. data/lib/git/pkgs/cli.rb +20 -8
  10. data/lib/git/pkgs/commands/blame.rb +0 -18
  11. data/lib/git/pkgs/commands/diff.rb +122 -5
  12. data/lib/git/pkgs/commands/diff_driver.rb +30 -4
  13. data/lib/git/pkgs/commands/init.rb +5 -0
  14. data/lib/git/pkgs/commands/licenses.rb +378 -0
  15. data/lib/git/pkgs/commands/list.rb +60 -15
  16. data/lib/git/pkgs/commands/outdated.rb +312 -0
  17. data/lib/git/pkgs/commands/show.rb +126 -3
  18. data/lib/git/pkgs/commands/stale.rb +6 -2
  19. data/lib/git/pkgs/commands/update.rb +3 -0
  20. data/lib/git/pkgs/commands/vulns/base.rb +358 -0
  21. data/lib/git/pkgs/commands/vulns/blame.rb +276 -0
  22. data/lib/git/pkgs/commands/vulns/diff.rb +173 -0
  23. data/lib/git/pkgs/commands/vulns/exposure.rb +418 -0
  24. data/lib/git/pkgs/commands/vulns/history.rb +345 -0
  25. data/lib/git/pkgs/commands/vulns/log.rb +218 -0
  26. data/lib/git/pkgs/commands/vulns/praise.rb +238 -0
  27. data/lib/git/pkgs/commands/vulns/scan.rb +231 -0
  28. data/lib/git/pkgs/commands/vulns/show.rb +216 -0
  29. data/lib/git/pkgs/commands/vulns/sync.rb +110 -0
  30. data/lib/git/pkgs/commands/vulns.rb +50 -0
  31. data/lib/git/pkgs/config.rb +8 -1
  32. data/lib/git/pkgs/database.rb +151 -5
  33. data/lib/git/pkgs/ecosystems.rb +83 -0
  34. data/lib/git/pkgs/ecosystems_client.rb +96 -0
  35. data/lib/git/pkgs/models/dependency_change.rb +8 -0
  36. data/lib/git/pkgs/models/dependency_snapshot.rb +8 -0
  37. data/lib/git/pkgs/models/package.rb +92 -0
  38. data/lib/git/pkgs/models/version.rb +27 -0
  39. data/lib/git/pkgs/models/vulnerability.rb +300 -0
  40. data/lib/git/pkgs/models/vulnerability_package.rb +59 -0
  41. data/lib/git/pkgs/osv_client.rb +151 -0
  42. data/lib/git/pkgs/output.rb +22 -0
  43. data/lib/git/pkgs/purl_helper.rb +56 -0
  44. data/lib/git/pkgs/spinner.rb +46 -0
  45. data/lib/git/pkgs/version.rb +1 -1
  46. data/lib/git/pkgs.rb +12 -0
  47. metadata +72 -4
@@ -13,9 +13,7 @@ module Git
13
13
 
14
14
  def run
15
15
  repo = Repository.new
16
- require_database(repo)
17
-
18
- Database.connect(repo.git_dir)
16
+ use_stateless = @options[:stateless] || !Database.exists?(repo.git_dir)
19
17
 
20
18
  from_ref, to_ref = parse_range_argument
21
19
  from_ref ||= @options[:from]
@@ -30,6 +28,46 @@ module Git
30
28
  error "Could not resolve '#{from_ref}'. Check that the ref exists." unless from_sha
31
29
  error "Could not resolve '#{to_ref}'. Check that the ref exists." unless to_sha
32
30
 
31
+ if use_stateless
32
+ run_stateless(repo, from_sha, to_sha)
33
+ else
34
+ run_with_database(repo, from_sha, to_sha)
35
+ end
36
+ end
37
+
38
+ def run_stateless(repo, from_sha, to_sha)
39
+ from_commit = repo.lookup(from_sha)
40
+ to_commit = repo.lookup(to_sha)
41
+
42
+ analyzer = Analyzer.new(repo)
43
+ diff = analyzer.diff_commits(from_commit, to_commit)
44
+
45
+ if @options[:ecosystem]
46
+ diff[:added] = diff[:added].select { |d| d[:ecosystem] == @options[:ecosystem] }
47
+ diff[:modified] = diff[:modified].select { |d| d[:ecosystem] == @options[:ecosystem] }
48
+ diff[:removed] = diff[:removed].select { |d| d[:ecosystem] == @options[:ecosystem] }
49
+ end
50
+
51
+ if diff[:added].empty? && diff[:modified].empty? && diff[:removed].empty?
52
+ if @options[:format] == "json"
53
+ require "json"
54
+ puts JSON.pretty_generate({ from: from_sha[0..7], to: to_sha[0..7], added: [], modified: [], removed: [] })
55
+ else
56
+ empty_result "No dependency changes between #{from_sha[0..7]} and #{to_sha[0..7]}"
57
+ end
58
+ return
59
+ end
60
+
61
+ if @options[:format] == "json"
62
+ output_json_stateless(from_sha, to_sha, diff)
63
+ else
64
+ paginate { output_text_stateless(from_sha, to_sha, diff) }
65
+ end
66
+ end
67
+
68
+ def run_with_database(repo, from_sha, to_sha)
69
+ Database.connect(repo.git_dir)
70
+
33
71
  from_commit = Models::Commit.find_or_create_from_repo(repo, from_sha)
34
72
  to_commit = Models::Commit.find_or_create_from_repo(repo, to_sha)
35
73
 
@@ -154,6 +192,81 @@ module Git
154
192
  puts JSON.pretty_generate(data)
155
193
  end
156
194
 
195
+ def output_text_stateless(from_sha, to_sha, diff)
196
+ puts "Dependency changes from #{from_sha[0..7]} to #{to_sha[0..7]}:"
197
+ puts
198
+
199
+ if diff[:added].any?
200
+ puts Color.green("Added:")
201
+ diff[:added].group_by { |d| d[:name] }.each do |name, pkg_changes|
202
+ latest = pkg_changes.last
203
+ puts Color.green(" + #{name} #{latest[:requirement]} (#{latest[:manifest_path]})")
204
+ end
205
+ puts
206
+ end
207
+
208
+ if diff[:modified].any?
209
+ puts Color.yellow("Modified:")
210
+ diff[:modified].group_by { |d| d[:name] }.each do |name, pkg_changes|
211
+ latest = pkg_changes.last
212
+ puts Color.yellow(" ~ #{name} #{latest[:previous_requirement]} -> #{latest[:requirement]}")
213
+ end
214
+ puts
215
+ end
216
+
217
+ if diff[:removed].any?
218
+ puts Color.red("Removed:")
219
+ diff[:removed].group_by { |d| d[:name] }.each do |name, pkg_changes|
220
+ latest = pkg_changes.last
221
+ puts Color.red(" - #{name} (was #{latest[:requirement]})")
222
+ end
223
+ puts
224
+ end
225
+
226
+ added_count = Color.green("+#{diff[:added].map { |d| d[:name] }.uniq.count}")
227
+ removed_count = Color.red("-#{diff[:removed].map { |d| d[:name] }.uniq.count}")
228
+ modified_count = Color.yellow("~#{diff[:modified].map { |d| d[:name] }.uniq.count}")
229
+ puts "Summary: #{added_count} #{removed_count} #{modified_count}"
230
+ end
231
+
232
+ def output_json_stateless(from_sha, to_sha, diff)
233
+ require "json"
234
+
235
+ format_change = lambda do |change|
236
+ {
237
+ name: change[:name],
238
+ ecosystem: change[:ecosystem],
239
+ requirement: change[:requirement],
240
+ manifest: change[:manifest_path]
241
+ }
242
+ end
243
+
244
+ format_modified = lambda do |change|
245
+ {
246
+ name: change[:name],
247
+ ecosystem: change[:ecosystem],
248
+ previous_requirement: change[:previous_requirement],
249
+ requirement: change[:requirement],
250
+ manifest: change[:manifest_path]
251
+ }
252
+ end
253
+
254
+ data = {
255
+ from: from_sha[0..7],
256
+ to: to_sha[0..7],
257
+ added: diff[:added].map { |c| format_change.call(c) },
258
+ modified: diff[:modified].map { |c| format_modified.call(c) },
259
+ removed: diff[:removed].map { |c| format_change.call(c) },
260
+ summary: {
261
+ added: diff[:added].map { |d| d[:name] }.uniq.count,
262
+ modified: diff[:modified].map { |d| d[:name] }.uniq.count,
263
+ removed: diff[:removed].map { |d| d[:name] }.uniq.count
264
+ }
265
+ }
266
+
267
+ puts JSON.pretty_generate(data)
268
+ end
269
+
157
270
  def parse_range_argument
158
271
  return [nil, nil] if @args.empty?
159
272
 
@@ -183,11 +296,11 @@ module Git
183
296
  opts.separator " git pkgs diff --from=v1.0 --to=v2.0"
184
297
  opts.separator ""
185
298
 
186
- opts.on("-f", "--from=REF", "Start commit") do |v|
299
+ opts.on("--from=REF", "Start commit") do |v|
187
300
  options[:from] = v
188
301
  end
189
302
 
190
- opts.on("-t", "--to=REF", "End commit (default: HEAD)") do |v|
303
+ opts.on("--to=REF", "End commit (default: HEAD)") do |v|
191
304
  options[:to] = v
192
305
  end
193
306
 
@@ -203,6 +316,10 @@ module Git
203
316
  options[:no_pager] = true
204
317
  end
205
318
 
319
+ opts.on("--stateless", "Parse manifests directly without database") do
320
+ options[:stateless] = true
321
+ end
322
+
206
323
  opts.on("-h", "--help", "Show this help") do
207
324
  puts opts
208
325
  exit
@@ -24,18 +24,24 @@ module Git
24
24
  gems.locked
25
25
  glide.lock
26
26
  go.mod
27
+ go.sum
28
+ gradle.lockfile
27
29
  mix.lock
28
30
  npm-shrinkwrap.json
29
31
  package-lock.json
30
32
  packages.lock.json
31
33
  paket.lock
34
+ pdm.lock
32
35
  pnpm-lock.yaml
33
36
  poetry.lock
34
37
  project.assets.json
35
38
  pubspec.lock
36
39
  pylock.toml
40
+ renv.lock
37
41
  shard.lock
42
+ stack.yaml.lock
38
43
  uv.lock
44
+ verification-metadata.xml
39
45
  yarn.lock
40
46
  ].freeze
41
47
 
@@ -80,10 +86,10 @@ module Git
80
86
 
81
87
  def install_driver
82
88
  # Set up git config for textconv
83
- system("git", "config", "diff.pkgs.textconv", "git-pkgs diff-driver")
89
+ git_config("diff.pkgs.textconv", "git-pkgs diff-driver")
84
90
 
85
91
  # Add to .gitattributes
86
- gitattributes_path = File.join(Dir.pwd, ".gitattributes")
92
+ gitattributes_path = File.join(work_tree, ".gitattributes")
87
93
  existing = File.exist?(gitattributes_path) ? File.read(gitattributes_path) : ""
88
94
 
89
95
  new_entries = []
@@ -109,9 +115,9 @@ module Git
109
115
  end
110
116
 
111
117
  def uninstall_driver
112
- system("git", "config", "--unset", "diff.pkgs.textconv")
118
+ git_config_unset("diff.pkgs.textconv")
113
119
 
114
- gitattributes_path = File.join(Dir.pwd, ".gitattributes")
120
+ gitattributes_path = File.join(work_tree, ".gitattributes")
115
121
  if File.exist?(gitattributes_path)
116
122
  lines = File.readlines(gitattributes_path)
117
123
  lines.reject! { |line| line.include?("diff=pkgs") || line.include?("# git-pkgs") }
@@ -140,6 +146,26 @@ module Git
140
146
  {}
141
147
  end
142
148
 
149
+ def work_tree
150
+ Git::Pkgs.work_tree || Dir.pwd
151
+ end
152
+
153
+ def git_cmd
154
+ if Git::Pkgs.git_dir
155
+ ["git", "-C", work_tree]
156
+ else
157
+ ["git"]
158
+ end
159
+ end
160
+
161
+ def git_config(key, value)
162
+ system(*git_cmd, "config", key, value)
163
+ end
164
+
165
+ def git_config_unset(key)
166
+ system(*git_cmd, "config", "--unset", key)
167
+ end
168
+
143
169
  def parse_options
144
170
  options = {}
145
171
 
@@ -102,6 +102,7 @@ module Git
102
102
  manifest_id: manifest_ids[c[:manifest_path]],
103
103
  name: c[:name],
104
104
  ecosystem: c[:ecosystem],
105
+ purl: c[:purl],
105
106
  change_type: c[:change_type],
106
107
  requirement: c[:requirement],
107
108
  previous_requirement: c[:previous_requirement],
@@ -121,6 +122,7 @@ module Git
121
122
  manifest_id: manifest_ids[s[:manifest_path]],
122
123
  name: s[:name],
123
124
  ecosystem: s[:ecosystem],
125
+ purl: s[:purl],
124
126
  requirement: s[:requirement],
125
127
  dependency_type: s[:dependency_type],
126
128
  created_at: now,
@@ -185,6 +187,7 @@ module Git
185
187
  manifest_path: manifest_key,
186
188
  name: change[:name],
187
189
  ecosystem: change[:ecosystem],
190
+ purl: change[:purl],
188
191
  change_type: change[:change_type],
189
192
  requirement: change[:requirement],
190
193
  previous_requirement: change[:previous_requirement],
@@ -202,6 +205,7 @@ module Git
202
205
  manifest_path: manifest_path,
203
206
  name: name,
204
207
  ecosystem: dep_info[:ecosystem],
208
+ purl: dep_info[:purl],
205
209
  requirement: dep_info[:requirement],
206
210
  dependency_type: dep_info[:dependency_type]
207
211
  }
@@ -222,6 +226,7 @@ module Git
222
226
  manifest_path: manifest_path,
223
227
  name: name,
224
228
  ecosystem: dep_info[:ecosystem],
229
+ purl: dep_info[:purl],
225
230
  requirement: dep_info[:requirement],
226
231
  dependency_type: dep_info[:dependency_type]
227
232
  }
@@ -0,0 +1,378 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Git
6
+ module Pkgs
7
+ module Commands
8
+ class Licenses
9
+ include Output
10
+
11
+ PERMISSIVE = %w[
12
+ MIT Apache-2.0 BSD-2-Clause BSD-3-Clause ISC Unlicense CC0-1.0
13
+ 0BSD WTFPL Zlib BSL-1.0
14
+ ].freeze
15
+
16
+ COPYLEFT = %w[
17
+ GPL-2.0 GPL-3.0 LGPL-2.1 LGPL-3.0 AGPL-3.0 MPL-2.0
18
+ GPL-2.0-only GPL-2.0-or-later GPL-3.0-only GPL-3.0-or-later
19
+ LGPL-2.1-only LGPL-2.1-or-later LGPL-3.0-only LGPL-3.0-or-later
20
+ AGPL-3.0-only AGPL-3.0-or-later
21
+ ].freeze
22
+
23
+ def self.description
24
+ "Show licenses for dependencies"
25
+ end
26
+
27
+ def initialize(args)
28
+ @args = args.dup
29
+ @options = parse_options
30
+ end
31
+
32
+ def parse_options
33
+ options = { allow: [], deny: [] }
34
+
35
+ parser = OptionParser.new do |opts|
36
+ opts.banner = "Usage: git pkgs licenses [options]"
37
+ opts.separator ""
38
+ opts.separator "Show licenses for dependencies with optional compliance checks."
39
+ opts.separator ""
40
+ opts.separator "Options:"
41
+
42
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
43
+ options[:ecosystem] = v
44
+ end
45
+
46
+ opts.on("-r", "--ref=REF", "Git ref to check (default: HEAD)") do |v|
47
+ options[:ref] = v
48
+ end
49
+
50
+ opts.on("-f", "--format=FORMAT", "Output format (text, json, csv)") do |v|
51
+ options[:format] = v
52
+ end
53
+
54
+ opts.on("--allow=LICENSES", "Comma-separated list of allowed licenses") do |v|
55
+ options[:allow] = v.split(",").map(&:strip)
56
+ end
57
+
58
+ opts.on("--deny=LICENSES", "Comma-separated list of denied licenses") do |v|
59
+ options[:deny] = v.split(",").map(&:strip)
60
+ end
61
+
62
+ opts.on("--permissive", "Only allow permissive licenses (MIT, Apache, BSD, etc.)") do
63
+ options[:permissive] = true
64
+ end
65
+
66
+ opts.on("--copyleft", "Flag copyleft licenses (GPL, AGPL, etc.)") do
67
+ options[:copyleft] = true
68
+ end
69
+
70
+ opts.on("--unknown", "Flag packages with unknown/missing licenses") do
71
+ options[:unknown] = true
72
+ end
73
+
74
+ opts.on("--group", "Group output by license") do
75
+ options[:group] = true
76
+ end
77
+
78
+ opts.on("--stateless", "Parse manifests directly without database") do
79
+ options[:stateless] = true
80
+ end
81
+
82
+ opts.on("-h", "--help", "Show this help") do
83
+ puts opts
84
+ exit
85
+ end
86
+ end
87
+
88
+ parser.parse!(@args)
89
+ options
90
+ end
91
+
92
+ def run
93
+ repo = Repository.new
94
+ use_stateless = @options[:stateless] || !Database.exists?(repo.git_dir)
95
+
96
+ if use_stateless
97
+ Database.connect_memory
98
+ deps = get_dependencies_stateless(repo)
99
+ else
100
+ Database.connect(repo.git_dir)
101
+ deps = get_dependencies_with_database(repo)
102
+ end
103
+
104
+ if deps.empty?
105
+ empty_result "No dependencies found"
106
+ return
107
+ end
108
+
109
+ if @options[:ecosystem]
110
+ deps = deps.select { |d| d[:ecosystem].downcase == @options[:ecosystem].downcase }
111
+ end
112
+
113
+ deps = Analyzer.pair_manifests_with_lockfiles(deps)
114
+
115
+ if deps.empty?
116
+ empty_result "No dependencies found"
117
+ return
118
+ end
119
+
120
+ packages = deps.map do |dep|
121
+ purl = PurlHelper.build_purl(ecosystem: dep[:ecosystem], name: dep[:name]).to_s
122
+ {
123
+ purl: purl,
124
+ name: dep[:name],
125
+ ecosystem: dep[:ecosystem],
126
+ version: dep[:requirement],
127
+ manifest_path: dep[:manifest_path]
128
+ }
129
+ end.uniq { |p| p[:purl] }
130
+
131
+ enrich_packages(packages.map { |p| p[:purl] })
132
+
133
+ packages.each do |pkg|
134
+ db_pkg = Models::Package.first(purl: pkg[:purl])
135
+ pkg[:license] = db_pkg&.license
136
+ pkg[:violation] = check_violation(pkg[:license])
137
+ end
138
+
139
+ violations = packages.select { |p| p[:violation] }
140
+
141
+ case @options[:format]
142
+ when "json"
143
+ output_json(packages, violations)
144
+ when "csv"
145
+ output_csv(packages)
146
+ else
147
+ if @options[:group]
148
+ output_grouped(packages, violations)
149
+ else
150
+ output_text(packages, violations)
151
+ end
152
+ end
153
+
154
+ exit 1 if violations.any?
155
+ end
156
+
157
+ def check_violation(license)
158
+ return "unknown" if @options[:unknown] && (license.nil? || license.empty?)
159
+
160
+ return nil if license.nil? || license.empty?
161
+
162
+ if @options[:permissive]
163
+ return "copyleft" if COPYLEFT.any? { |l| license_matches?(license, l) }
164
+ return "not-permissive" unless PERMISSIVE.any? { |l| license_matches?(license, l) }
165
+ end
166
+
167
+ if @options[:copyleft]
168
+ return "copyleft" if COPYLEFT.any? { |l| license_matches?(license, l) }
169
+ end
170
+
171
+ if @options[:allow].any?
172
+ return "not-allowed" unless @options[:allow].any? { |l| license_matches?(license, l) }
173
+ end
174
+
175
+ if @options[:deny].any?
176
+ return "denied" if @options[:deny].any? { |l| license_matches?(license, l) }
177
+ end
178
+
179
+ nil
180
+ end
181
+
182
+ def license_matches?(license, pattern)
183
+ license.downcase.include?(pattern.downcase)
184
+ end
185
+
186
+ def enrich_packages(purls)
187
+ packages_by_purl = {}
188
+ purls.each do |purl|
189
+ parsed = Purl::PackageURL.parse(purl)
190
+ ecosystem = PurlHelper::ECOSYSTEM_TO_PURL_TYPE.invert[parsed.type] || parsed.type
191
+ pkg = Models::Package.find_or_create_by_purl(
192
+ purl: purl,
193
+ ecosystem: ecosystem,
194
+ name: parsed.name
195
+ )
196
+ packages_by_purl[purl] = pkg
197
+ end
198
+
199
+ stale_purls = packages_by_purl.select { |_, pkg| pkg.needs_enrichment? }.keys
200
+ return if stale_purls.empty?
201
+
202
+ client = EcosystemsClient.new
203
+ begin
204
+ results = Spinner.with_spinner("Fetching package metadata...") do
205
+ client.bulk_lookup(stale_purls)
206
+ end
207
+ results.each do |purl, data|
208
+ packages_by_purl[purl]&.enrich_from_api(data)
209
+ end
210
+ rescue EcosystemsClient::ApiError => e
211
+ $stderr.puts "Warning: Could not fetch package data: #{e.message}" unless Git::Pkgs.quiet
212
+ end
213
+ end
214
+
215
+ def output_text(packages, violations)
216
+ max_name = packages.map { |p| p[:name].length }.max || 20
217
+ max_license = packages.map { |p| (p[:license] || "").length }.max || 10
218
+ max_license = [max_license, 20].min
219
+
220
+ packages.sort_by { |p| [p[:license] || "zzz", p[:name]] }.each do |pkg|
221
+ name = pkg[:name].ljust(max_name)
222
+ license = (pkg[:license] || "unknown").ljust(max_license)[0, max_license]
223
+ ecosystem = pkg[:ecosystem]
224
+
225
+ line = "#{name} #{license} (#{ecosystem})"
226
+
227
+ colored = if pkg[:violation]
228
+ Color.red("#{line} [#{pkg[:violation]}]")
229
+ else
230
+ line
231
+ end
232
+
233
+ puts colored
234
+ end
235
+
236
+ output_summary(packages, violations)
237
+ end
238
+
239
+ def output_grouped(packages, violations)
240
+ by_license = packages.group_by { |p| p[:license] || "unknown" }
241
+
242
+ by_license.sort_by { |license, _| license.downcase }.each do |license, pkgs|
243
+ has_violation = pkgs.any? { |p| p[:violation] }
244
+ header = "#{license} (#{pkgs.size})"
245
+ puts has_violation ? Color.red(header) : Color.bold(header)
246
+
247
+ pkgs.sort_by { |p| p[:name] }.each do |pkg|
248
+ puts " #{pkg[:name]}"
249
+ end
250
+ puts ""
251
+ end
252
+
253
+ output_summary(packages, violations)
254
+ end
255
+
256
+ def output_summary(packages, violations)
257
+ return unless violations.any?
258
+
259
+ puts ""
260
+ puts Color.red("#{violations.size} license violation#{"s" if violations.size != 1} found")
261
+ end
262
+
263
+ def output_json(packages, violations)
264
+ require "json"
265
+ puts JSON.pretty_generate({
266
+ packages: packages,
267
+ summary: {
268
+ total: packages.size,
269
+ violations: violations.size,
270
+ by_license: packages.group_by { |p| p[:license] || "unknown" }.transform_values(&:size)
271
+ }
272
+ })
273
+ end
274
+
275
+ def output_csv(packages)
276
+ puts "name,ecosystem,version,license,violation"
277
+ packages.sort_by { |p| p[:name] }.each do |pkg|
278
+ puts [
279
+ pkg[:name],
280
+ pkg[:ecosystem],
281
+ pkg[:version],
282
+ pkg[:license] || "",
283
+ pkg[:violation] || ""
284
+ ].map { |v| csv_escape(v) }.join(",")
285
+ end
286
+ end
287
+
288
+ def csv_escape(value)
289
+ if value.to_s.include?(",") || value.to_s.include?('"')
290
+ "\"#{value.to_s.gsub('"', '""')}\""
291
+ else
292
+ value.to_s
293
+ end
294
+ end
295
+
296
+ def get_dependencies_stateless(repo)
297
+ ref = @options[:ref] || "HEAD"
298
+ commit_sha = repo.rev_parse(ref)
299
+ rugged_commit = repo.lookup(commit_sha)
300
+
301
+ error "Could not resolve '#{ref}'" unless rugged_commit
302
+
303
+ analyzer = Analyzer.new(repo)
304
+ analyzer.dependencies_at_commit(rugged_commit)
305
+ end
306
+
307
+ def get_dependencies_with_database(repo)
308
+ ref = @options[:ref] || "HEAD"
309
+ commit_sha = repo.rev_parse(ref)
310
+ target_commit = Models::Commit.first(sha: commit_sha)
311
+
312
+ return get_dependencies_stateless(repo) unless target_commit
313
+
314
+ branch_name = repo.default_branch
315
+ branch = Models::Branch.first(name: branch_name)
316
+ return [] unless branch
317
+
318
+ compute_dependencies_at_commit(target_commit, branch)
319
+ end
320
+
321
+ def compute_dependencies_at_commit(target_commit, branch)
322
+ snapshot_commit = branch.commits_dataset
323
+ .join(:dependency_snapshots, commit_id: :id)
324
+ .where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
325
+ .order(Sequel.desc(Sequel[:commits][:committed_at]))
326
+ .distinct
327
+ .first
328
+
329
+ deps = {}
330
+ if snapshot_commit
331
+ snapshot_commit.dependency_snapshots.each do |s|
332
+ key = [s.manifest.path, s.name]
333
+ deps[key] = {
334
+ manifest_path: s.manifest.path,
335
+ manifest_kind: s.manifest.kind,
336
+ name: s.name,
337
+ ecosystem: s.ecosystem,
338
+ requirement: s.requirement,
339
+ dependency_type: s.dependency_type
340
+ }
341
+ end
342
+ end
343
+
344
+ if snapshot_commit && snapshot_commit.id != target_commit.id
345
+ commit_ids = branch.commits_dataset.select_map(Sequel[:commits][:id])
346
+ changes = Models::DependencyChange
347
+ .join(:commits, id: :commit_id)
348
+ .where(Sequel[:commits][:id] => commit_ids)
349
+ .where { Sequel[:commits][:committed_at] > snapshot_commit.committed_at }
350
+ .where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
351
+ .order(Sequel[:commits][:committed_at])
352
+ .eager(:manifest)
353
+ .all
354
+
355
+ changes.each do |change|
356
+ key = [change.manifest.path, change.name]
357
+ case change.change_type
358
+ when "added", "modified"
359
+ deps[key] = {
360
+ manifest_path: change.manifest.path,
361
+ manifest_kind: change.manifest.kind,
362
+ name: change.name,
363
+ ecosystem: change.ecosystem,
364
+ requirement: change.requirement,
365
+ dependency_type: change.dependency_type
366
+ }
367
+ when "removed"
368
+ deps.delete(key)
369
+ end
370
+ end
371
+ end
372
+
373
+ deps.values
374
+ end
375
+ end
376
+ end
377
+ end
378
+ end