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

Commit 332c036

Browse files
committed
Add --at flag to outdated command for historical version checks
1 parent f5e1266 commit 332c036

File tree

8 files changed

+434
-4
lines changed

8 files changed

+434
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## [Unreleased]
22

3+
- `--at` flag for `outdated` command to check what was outdated at a specific date or git ref
34
- Auto-upgrade outdated database schemas instead of erroring
45
- Fix `outdated` command suggesting downgrades when current version is newer than registry
56

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,11 +268,15 @@ Shows dependencies sorted by how long since they were last changed in your repo.
268268
git pkgs outdated # show packages with newer versions available
269269
git pkgs outdated --major # only major version updates
270270
git pkgs outdated --minor # minor and major updates (skip patch)
271+
git pkgs outdated --at v2.0 # what was outdated when we released v2.0?
272+
git pkgs outdated --at 2024-03-01 # what was outdated on this date?
271273
git pkgs outdated --stateless # no database needed
272274
```
273275

274276
Checks package registries (via [ecosyste.ms](https://packages.ecosyste.ms/)) to find dependencies with newer versions available. Major updates are shown in red, minor in yellow, patch in cyan.
275277

278+
The `--at` flag enables time travel: pass a date (YYYY-MM-DD) or any git ref (tag, branch, commit SHA) to see what was outdated at that point in time. When given a git ref, it uses the commit's date.
279+
276280
### Check licenses
277281

278282
```bash

lib/git/pkgs/commands/outdated.rb

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ def initialize(args)
1717
@options = parse_options
1818
end
1919

20+
def parse_date(value)
21+
Time.parse(value)
22+
rescue ArgumentError
23+
# Not a date, will try as git ref in run()
24+
value
25+
end
26+
2027
def parse_options
2128
options = {}
2229

@@ -51,6 +58,10 @@ def parse_options
5158
options[:stateless] = true
5259
end
5360

61+
opts.on("--at=DATE", "Show what was outdated at DATE (YYYY-MM-DD)") do |v|
62+
options[:at] = parse_date(v)
63+
end
64+
5465
opts.on("-h", "--help", "Show this help") do
5566
puts opts
5667
exit
@@ -73,6 +84,8 @@ def run
7384
deps = get_dependencies_with_database(repo)
7485
end
7586

87+
resolve_at_option(repo) if @options[:at]
88+
7689
if deps.empty?
7790
empty_result "No dependencies found"
7891
return
@@ -102,14 +115,19 @@ def run
102115
}
103116
end.uniq { |p| p[:purl] }
104117

105-
enrich_packages(packages_to_check.map { |p| p[:purl] })
118+
purls = packages_to_check.map { |p| p[:purl] }
119+
120+
if @options[:at]
121+
enrich_version_history(purls)
122+
else
123+
enrich_packages(purls)
124+
end
106125

107126
outdated = []
108127
packages_to_check.each do |pkg|
109-
db_pkg = Models::Package.first(purl: pkg[:purl])
110-
next unless db_pkg&.latest_version
128+
latest = get_latest_version(pkg[:purl])
129+
next unless latest
111130

112-
latest = db_pkg.latest_version
113131
current = pkg[:current_version]
114132

115133
next if current == latest
@@ -141,6 +159,62 @@ def run
141159
end
142160
end
143161

162+
def resolve_at_option(repo)
163+
return if @options[:at].is_a?(Time)
164+
165+
ref = @options[:at].to_s
166+
begin
167+
sha = repo.rev_parse(ref)
168+
commit = repo.lookup(sha)
169+
@options[:at] = commit.time
170+
rescue Rugged::ReferenceError, Rugged::InvalidError
171+
$stderr.puts "Invalid git ref or date: #{ref}. Use YYYY-MM-DD or a valid git ref."
172+
exit 1
173+
end
174+
end
175+
176+
def get_latest_version(purl)
177+
if @options[:at]
178+
version = Models::Version.latest_as_of(package_purl: purl, date: @options[:at])
179+
version&.version_string
180+
else
181+
db_pkg = Models::Package.first(purl: purl)
182+
db_pkg&.latest_version
183+
end
184+
end
185+
186+
def enrich_version_history(purls)
187+
client = EcosystemsClient.new
188+
189+
Spinner.with_spinner("Fetching version history...") do
190+
purls.each do |purl|
191+
existing_count = Models::Version.where(package_purl: purl)
192+
.where(Sequel.~(published_at: nil))
193+
.count
194+
next if existing_count > 0
195+
196+
versions = client.lookup_all_versions(purl)
197+
next unless versions
198+
199+
versions.each do |v|
200+
next unless v["number"] && v["published_at"]
201+
202+
version_purl = "#{purl}@#{v["number"]}"
203+
version = Models::Version.find_or_create_by_purl(
204+
purl: version_purl,
205+
package_purl: purl
206+
)
207+
version.update(
208+
published_at: Time.parse(v["published_at"]),
209+
enriched_at: Time.now
210+
)
211+
end
212+
end
213+
end
214+
rescue EcosystemsClient::ApiError => e
215+
$stderr.puts "Warning: Could not fetch version history: #{e.message}" unless Git::Pkgs.quiet
216+
end
217+
144218
def enrich_packages(purls)
145219
packages_by_purl = {}
146220
purls.each do |purl|

lib/git/pkgs/ecosystems_client.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,36 @@ def bulk_lookup_versions(purls)
7878
results
7979
end
8080

81+
# Lookup all versions for a package by purl.
82+
# Returns version history with published_at dates.
83+
#
84+
# @param purl [String] package URL without version (e.g., "pkg:gem/rails")
85+
# @return [Array<Hash>, nil] array of version data or nil if not found
86+
def lookup_all_versions(purl)
87+
parsed = Purl.parse(purl)
88+
base_url = parsed.ecosystems_package_api_url
89+
return nil unless base_url
90+
91+
fetch_all_pages("#{base_url}/versions")
92+
rescue Purl::Error
93+
nil
94+
end
95+
96+
# Batch lookup all versions for multiple packages.
97+
# Currently fetches each package individually.
98+
# Designed for future batch API support.
99+
#
100+
# @param purls [Array<String>] array of package URLs without versions
101+
# @return [Hash<String, Array<Hash>>] hash keyed by purl with version arrays
102+
def bulk_lookup_all_versions(purls)
103+
results = {}
104+
purls.each do |purl|
105+
data = lookup_all_versions(purl)
106+
results[purl] = data if data
107+
end
108+
results
109+
end
110+
81111
private
82112

83113
def fetch_url(url)
@@ -87,6 +117,24 @@ def fetch_url(url)
87117
execute_request(uri, request)
88118
end
89119

120+
def fetch_all_pages(base_url, per_page: 100)
121+
results = []
122+
page = 1
123+
124+
loop do
125+
url = "#{base_url}?page=#{page}&per_page=#{per_page}"
126+
data = fetch_url(url)
127+
break unless data.is_a?(Array) && data.any?
128+
129+
results.concat(data)
130+
break if data.length < per_page
131+
132+
page += 1
133+
end
134+
135+
results.empty? ? nil : results
136+
end
137+
90138
def get(path)
91139
uri = URI("#{API_BASE}#{path}")
92140
request = Net::HTTP::Get.new(uri)

lib/git/pkgs/models/version.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,29 @@ def self.find_or_create_by_purl(purl:, package_purl:)
5050

5151
create(purl: purl, package_purl: package_purl)
5252
end
53+
54+
# Find the latest version of a package that was published before a given date.
55+
# Compares versions using semantic versioning.
56+
#
57+
# @param package_purl [String] base purl without version
58+
# @param date [Time] cutoff date
59+
# @return [Version, nil] the latest version as of that date
60+
def self.latest_as_of(package_purl:, date:)
61+
candidates = where(package_purl: package_purl)
62+
.where { published_at <= date }
63+
.where(Sequel.~(published_at: nil))
64+
.all
65+
66+
return nil if candidates.empty?
67+
68+
candidates.max_by { |v| parse_semver(v.version_string) }
69+
end
70+
71+
def self.parse_semver(version)
72+
cleaned = version.to_s.sub(/^v/i, "")
73+
parts = cleaned.split(".").first(3).map(&:to_i)
74+
parts + [0] * (3 - parts.length)
75+
end
5376
end
5477
end
5578
end

test/git/pkgs/test_ecosystems_client.rb

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,73 @@ def test_api_error_on_timeout
116116
@client.bulk_lookup(["pkg:gem/rails"])
117117
end
118118
end
119+
120+
def test_lookup_all_versions
121+
stub_request(:get, "https://packages.ecosyste.ms/api/v1/registries/rubygems.org/packages/rails/versions?page=1&per_page=100")
122+
.to_return(
123+
status: 200,
124+
body: [
125+
{ "number" => "7.0.0", "published_at" => "2021-12-15T00:00:00Z" },
126+
{ "number" => "7.1.0", "published_at" => "2023-10-05T00:00:00Z" }
127+
].to_json,
128+
headers: { "Content-Type" => "application/json" }
129+
)
130+
131+
versions = @client.lookup_all_versions("pkg:gem/rails")
132+
133+
assert_equal 2, versions.size
134+
assert_equal "7.0.0", versions[0]["number"]
135+
assert_equal "7.1.0", versions[1]["number"]
136+
end
137+
138+
def test_lookup_all_versions_paginates
139+
stub_request(:get, "https://packages.ecosyste.ms/api/v1/registries/rubygems.org/packages/rails/versions?page=1&per_page=100")
140+
.to_return(
141+
status: 200,
142+
body: (1..100).map { |i| { "number" => "1.0.#{i}" } }.to_json,
143+
headers: { "Content-Type" => "application/json" }
144+
)
145+
146+
stub_request(:get, "https://packages.ecosyste.ms/api/v1/registries/rubygems.org/packages/rails/versions?page=2&per_page=100")
147+
.to_return(
148+
status: 200,
149+
body: [{ "number" => "2.0.0" }].to_json,
150+
headers: { "Content-Type" => "application/json" }
151+
)
152+
153+
versions = @client.lookup_all_versions("pkg:gem/rails")
154+
155+
assert_equal 101, versions.size
156+
end
157+
158+
def test_lookup_all_versions_returns_nil_for_not_found
159+
stub_request(:get, %r{packages.ecosyste.ms/api/v1/registries/rubygems.org/packages/nonexistent/versions})
160+
.to_return(status: 404)
161+
162+
result = @client.lookup_all_versions("pkg:gem/nonexistent")
163+
164+
assert_nil result
165+
end
166+
167+
def test_bulk_lookup_all_versions
168+
stub_request(:get, %r{packages.ecosyste.ms/api/v1/registries/rubygems.org/packages/rails/versions})
169+
.to_return(
170+
status: 200,
171+
body: [{ "number" => "7.1.0", "published_at" => "2023-10-05T00:00:00Z" }].to_json,
172+
headers: { "Content-Type" => "application/json" }
173+
)
174+
175+
stub_request(:get, %r{packages.ecosyste.ms/api/v1/registries/npmjs.org/packages/lodash/versions})
176+
.to_return(
177+
status: 200,
178+
body: [{ "number" => "4.17.21", "published_at" => "2021-02-20T00:00:00Z" }].to_json,
179+
headers: { "Content-Type" => "application/json" }
180+
)
181+
182+
results = @client.bulk_lookup_all_versions(["pkg:gem/rails", "pkg:npm/lodash"])
183+
184+
assert_equal 2, results.size
185+
assert_equal 1, results["pkg:gem/rails"].size
186+
assert_equal 1, results["pkg:npm/lodash"].size
187+
end
119188
end

0 commit comments

Comments
 (0)