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

Commit 1538a04

Browse files
committed
Add manifest filtering and stateless parsing API
1 parent 9fc4400 commit 1538a04

File tree

6 files changed

+398
-0
lines changed

6 files changed

+398
-0
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
- `--format=json` support for `diff`, `tree`, `stale`, and `why` commands
66
- Ignore go.sum (checksums only), treat go.mod as lockfile
77
- Update ecosystems-bibliothecary to ~> 15.1
8+
- `--manifest` filter for `list` command to filter by manifest path
9+
- Stateless parsing API for forge integration (`Git::Pkgs.parse_file`, `parse_files`, `diff_file`)
810

911
## [0.6.1] - 2026-01-05
1012

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ Snapshot Coverage
9595
git pkgs list
9696
git pkgs list --commit=abc123
9797
git pkgs list --ecosystem=rubygems
98+
git pkgs list --manifest=Gemfile
9899
```
99100

100101
Example output:
@@ -433,6 +434,37 @@ Actions, BentoML, Bower, Cargo, Carthage, Clojars, CocoaPods, Cog, Conda, CPAN,
433434

434435
SBOM formats (CycloneDX, SPDX) are not supported as they duplicate information from the actual lockfiles.
435436

437+
## Ruby API
438+
439+
For embedding in other tools (like forges), git-pkgs provides a stateless parsing API that doesn't require initializing a database:
440+
441+
```ruby
442+
require "git/pkgs"
443+
444+
# Parse a single manifest file
445+
result = Git::Pkgs.parse_file("Gemfile", content)
446+
# => { platform: "rubygems", kind: "manifest", dependencies: [...] }
447+
448+
# Parse multiple files at once
449+
results = Git::Pkgs.parse_files({
450+
"Gemfile" => gemfile_content,
451+
"package.json" => package_json_content
452+
})
453+
454+
# Diff two versions of a manifest
455+
diff = Git::Pkgs.diff_file("Gemfile", old_content, new_content)
456+
# => { path: "Gemfile", platform: "rubygems", added: [...], modified: [...], removed: [...] }
457+
```
458+
459+
The diff_file method returns modified dependencies with a `previous_requirement` field showing the old version.
460+
461+
For database queries, connect to an existing database and use the Sequel models directly:
462+
463+
```ruby
464+
Git::Pkgs::Database.connect(repo_git_dir)
465+
Git::Pkgs::Models::DependencyChange.where(name: "rails").all
466+
```
467+
436468
## Contributing
437469

438470
Bug reports, feature requests, and pull requests are welcome. If you're unsure about a change, open an issue first to discuss it.

lib/git/pkgs.rb

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,77 @@ class NotInGitRepoError < Error; end
4747
class << self
4848
attr_accessor :quiet, :git_dir, :work_tree, :db_path, :batch_size, :snapshot_interval, :threads
4949

50+
# Parse dependencies from a single manifest or lockfile.
51+
# Returns nil if the file is not recognized as a manifest.
52+
#
53+
# @param path [String] file path (used for format detection)
54+
# @param content [String] file contents
55+
# @return [Hash, nil] parsed manifest with :platform, :path, :kind, :dependencies keys
56+
def parse_file(path, content)
57+
Config.configure_bibliothecary
58+
result = Bibliothecary.analyse_file(path, content).first
59+
return nil unless result
60+
return nil if Config.filter_ecosystem?(result[:platform])
61+
62+
result
63+
end
64+
65+
# Parse dependencies from multiple files.
66+
# Returns only files that are recognized as manifests.
67+
#
68+
# @param files [Hash<String, String>] hash of path => content
69+
# @return [Array<Hash>] array of parsed manifests
70+
def parse_files(files)
71+
Config.configure_bibliothecary
72+
files.filter_map do |path, content|
73+
result = Bibliothecary.analyse_file(path, content).first
74+
next unless result
75+
next if Config.filter_ecosystem?(result[:platform])
76+
77+
result
78+
end
79+
end
80+
81+
# Diff dependencies between two versions of a manifest file.
82+
# Returns added, modified, and removed dependencies.
83+
#
84+
# @param path [String] file path (used for format detection)
85+
# @param old_content [String] previous file contents (empty string for new files)
86+
# @param new_content [String] current file contents (empty string for deleted files)
87+
# @return [Hash] with :added, :modified, :removed arrays and :platform, :path keys
88+
def diff_file(path, old_content, new_content)
89+
Config.configure_bibliothecary
90+
91+
old_result = old_content.empty? ? nil : Bibliothecary.analyse_file(path, old_content).first
92+
new_result = new_content.empty? ? nil : Bibliothecary.analyse_file(path, new_content).first
93+
94+
platform = new_result&.dig(:platform) || old_result&.dig(:platform)
95+
return nil unless platform
96+
return nil if Config.filter_ecosystem?(platform)
97+
98+
old_deps = (old_result&.dig(:dependencies) || []).map { |d| [d[:name], d] }.to_h
99+
new_deps = (new_result&.dig(:dependencies) || []).map { |d| [d[:name], d] }.to_h
100+
101+
added = (new_deps.keys - old_deps.keys).map { |n| new_deps[n] }
102+
removed = (old_deps.keys - new_deps.keys).map { |n| old_deps[n] }
103+
modified = (old_deps.keys & new_deps.keys).filter_map do |name|
104+
old_dep = old_deps[name]
105+
new_dep = new_deps[name]
106+
next if old_dep[:requirement] == new_dep[:requirement] && old_dep[:type] == new_dep[:type]
107+
108+
new_dep.to_h.merge(previous_requirement: old_dep[:requirement])
109+
end
110+
111+
{
112+
path: path,
113+
platform: platform,
114+
kind: new_result&.dig(:kind) || old_result&.dig(:kind),
115+
added: added,
116+
modified: modified,
117+
removed: removed
118+
}
119+
end
120+
50121
def configure_from_env
51122
@git_dir ||= presence(ENV["GIT_DIR"])
52123
@work_tree ||= presence(ENV["GIT_WORK_TREE"])

lib/git/pkgs/commands/list.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ def run
2525
deps = compute_dependencies_at_commit(target_commit, repo)
2626

2727
# Apply filters
28+
if @options[:manifest]
29+
deps = deps.select { |d| d[:manifest_path] == @options[:manifest] }
30+
end
31+
2832
if @options[:ecosystem]
2933
deps = deps.select { |d| d[:ecosystem] == @options[:ecosystem] }
3034
end
@@ -133,6 +137,10 @@ def parse_options
133137
options[:ecosystem] = v
134138
end
135139

140+
opts.on("-m", "--manifest=PATH", "Filter by manifest path") do |v|
141+
options[:manifest] = v
142+
end
143+
136144
opts.on("-t", "--type=TYPE", "Filter by dependency type") do |v|
137145
options[:type] = v
138146
end

test/git/pkgs/test_cli.rb

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1849,6 +1849,89 @@ def test_list_filters_by_ecosystem
18491849

18501850
assert_includes output, "No dependencies found"
18511851
end
1852+
1853+
def test_list_filters_by_manifest
1854+
sha = SecureRandom.hex(20)
1855+
commit = Git::Pkgs::Models::Commit.create(
1856+
sha: sha, message: "Add deps",
1857+
author_name: "alice", author_email: "alice@example.com",
1858+
committed_at: Time.now, has_dependency_changes: true
1859+
)
1860+
1861+
gemfile = Git::Pkgs::Models::Manifest.find_or_create(path: "Gemfile", ecosystem: "rubygems", kind: "manifest")
1862+
package_json = Git::Pkgs::Models::Manifest.find_or_create(path: "package.json", ecosystem: "npm", kind: "manifest")
1863+
1864+
branch = Git::Pkgs::Models::Branch.create(name: "main", last_analyzed_sha: sha)
1865+
Git::Pkgs::Models::BranchCommit.create(branch: branch, commit: commit, position: 1)
1866+
1867+
Git::Pkgs::Models::DependencySnapshot.create(
1868+
commit: commit, manifest: gemfile, name: "rails",
1869+
ecosystem: "rubygems", requirement: "~> 7.0", dependency_type: "runtime"
1870+
)
1871+
Git::Pkgs::Models::DependencySnapshot.create(
1872+
commit: commit, manifest: package_json, name: "lodash",
1873+
ecosystem: "npm", requirement: "^4.0", dependency_type: "runtime"
1874+
)
1875+
1876+
output = capture_stdout do
1877+
Dir.chdir(@test_dir) do
1878+
Git::Pkgs::Commands::List.new(["--commit=#{sha}", "--manifest=Gemfile"]).run
1879+
end
1880+
end
1881+
1882+
assert_includes output, "rails"
1883+
refute_includes output, "lodash"
1884+
end
1885+
1886+
def test_list_manifest_filter_no_match
1887+
commit = create_commit_with_changes("alice", [
1888+
{ name: "rails", change_type: "added" }
1889+
])
1890+
create_branch_with_snapshot("main", commit, [{ name: "rails" }])
1891+
1892+
output = capture_stdout do
1893+
Dir.chdir(@test_dir) do
1894+
Git::Pkgs::Commands::List.new(["--commit=#{commit.sha}", "--manifest=package.json"]).run
1895+
end
1896+
end
1897+
1898+
assert_includes output, "No dependencies found"
1899+
end
1900+
1901+
def test_list_manifest_filter_json_format
1902+
sha = SecureRandom.hex(20)
1903+
commit = Git::Pkgs::Models::Commit.create(
1904+
sha: sha, message: "Add deps",
1905+
author_name: "alice", author_email: "alice@example.com",
1906+
committed_at: Time.now, has_dependency_changes: true
1907+
)
1908+
1909+
gemfile = Git::Pkgs::Models::Manifest.find_or_create(path: "Gemfile", ecosystem: "rubygems", kind: "manifest")
1910+
package_json = Git::Pkgs::Models::Manifest.find_or_create(path: "package.json", ecosystem: "npm", kind: "manifest")
1911+
1912+
branch = Git::Pkgs::Models::Branch.create(name: "main", last_analyzed_sha: sha)
1913+
Git::Pkgs::Models::BranchCommit.create(branch: branch, commit: commit, position: 1)
1914+
1915+
Git::Pkgs::Models::DependencySnapshot.create(
1916+
commit: commit, manifest: gemfile, name: "rails",
1917+
ecosystem: "rubygems", requirement: "~> 7.0", dependency_type: "runtime"
1918+
)
1919+
Git::Pkgs::Models::DependencySnapshot.create(
1920+
commit: commit, manifest: package_json, name: "lodash",
1921+
ecosystem: "npm", requirement: "^4.0", dependency_type: "runtime"
1922+
)
1923+
1924+
output = capture_stdout do
1925+
Dir.chdir(@test_dir) do
1926+
Git::Pkgs::Commands::List.new(["--commit=#{sha}", "--manifest=Gemfile", "--format=json"]).run
1927+
end
1928+
end
1929+
1930+
data = JSON.parse(output)
1931+
assert_equal 1, data.length
1932+
assert_equal "rails", data.first["name"]
1933+
assert_equal "Gemfile", data.first["manifest_path"]
1934+
end
18521935
end
18531936

18541937
class Git::Pkgs::TestStaleCommand < Git::Pkgs::CommandTestBase

0 commit comments

Comments
 (0)