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

Commit 19b2919

Browse files
committed
Add 'where' command to locate packages in manifest files
1 parent 3730d1c commit 19b2919

File tree

7 files changed

+288
-1
lines changed

7 files changed

+288
-1
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+
- `git pkgs where` command to find where a package is declared in manifest files
34
- `git pkgs diff-driver` command for semantic lockfile diffs in `git diff`
45

56
## [0.3.0] - 2026-01-03

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,23 @@ git pkgs show HEAD~5 # relative ref
258258

259259
Like `git show` but for dependencies. Shows what was added, modified, or removed in a single commit.
260260

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+
261278
### List commits with dependency changes
262279

263280
```bash

lib/git/pkgs.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
require_relative "pkgs/commands/branch"
3131
require_relative "pkgs/commands/search"
3232
require_relative "pkgs/commands/show"
33+
require_relative "pkgs/commands/where"
3334
require_relative "pkgs/commands/log"
3435
require_relative "pkgs/commands/upgrade"
3536
require_relative "pkgs/commands/schema"

lib/git/pkgs/cli.rb

Lines changed: 2 additions & 1 deletion
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 diff-driver].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
99
ALIASES = { "praise" => "blame", "outdated" => "stale" }.freeze
1010

1111
def self.run(args)
@@ -59,6 +59,7 @@ def print_help
5959
tree Show dependency tree grouped by type
6060
history Show the history of a package
6161
search Find a dependency across all history
62+
where Show where a package appears in manifest files
6263
why Explain why a dependency exists
6364
blame Show who added each dependency
6465
stale Show dependencies that haven't been updated

lib/git/pkgs/color.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def self.red(text) = colorize(text, :red)
7474
def self.green(text) = colorize(text, :green)
7575
def self.yellow(text) = colorize(text, :yellow)
7676
def self.blue(text) = colorize(text, :blue)
77+
def self.magenta(text) = colorize(text, :magenta)
7778
def self.cyan(text) = colorize(text, :cyan)
7879
def self.bold(text) = colorize(text, :bold)
7980
def self.dim(text) = colorize(text, :dim)

lib/git/pkgs/commands/where.rb

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

test/git/pkgs/test_cli.rb

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,106 @@ def capture_stdout
833833
end
834834
end
835835

836+
class Git::Pkgs::TestWhereCommand < Minitest::Test
837+
include TestHelpers
838+
839+
def setup
840+
create_test_repo
841+
add_file("Gemfile", sample_gemfile({ "rails" => "~> 7.0", "puma" => "~> 5.0" }))
842+
commit("Add dependencies")
843+
@git_dir = File.join(@test_dir, ".git")
844+
Git::Pkgs::Database.connect(@git_dir)
845+
Git::Pkgs::Database.create_schema
846+
847+
# Create branch and snapshot
848+
repo = Git::Pkgs::Repository.new(@test_dir)
849+
Git::Pkgs::Models::Branch.create!(name: repo.default_branch, last_analyzed_sha: repo.head_sha)
850+
rugged_commit = repo.lookup(repo.head_sha)
851+
commit_record = Git::Pkgs::Models::Commit.find_or_create_from_rugged(rugged_commit)
852+
853+
manifest = Git::Pkgs::Models::Manifest.create!(
854+
path: "Gemfile",
855+
ecosystem: "rubygems",
856+
kind: "manifest"
857+
)
858+
859+
Git::Pkgs::Models::DependencySnapshot.create!(
860+
commit: commit_record,
861+
manifest: manifest,
862+
name: "rails",
863+
ecosystem: "rubygems",
864+
requirement: "~> 7.0"
865+
)
866+
867+
Git::Pkgs::Models::DependencySnapshot.create!(
868+
commit: commit_record,
869+
manifest: manifest,
870+
name: "puma",
871+
ecosystem: "rubygems",
872+
requirement: "~> 5.0"
873+
)
874+
end
875+
876+
def teardown
877+
cleanup_test_repo
878+
end
879+
880+
def test_where_finds_package_in_manifest
881+
output = capture_stdout do
882+
Dir.chdir(@test_dir) do
883+
Git::Pkgs::Commands::Where.new(["rails"]).run
884+
end
885+
end
886+
887+
assert_includes output, "Gemfile"
888+
assert_includes output, "rails"
889+
end
890+
891+
def test_where_shows_line_number
892+
output = capture_stdout do
893+
Dir.chdir(@test_dir) do
894+
Git::Pkgs::Commands::Where.new(["rails"]).run
895+
end
896+
end
897+
898+
# Output format: path:line:content
899+
assert_match(/Gemfile:\d+:.*rails/, output)
900+
end
901+
902+
def test_where_not_found
903+
output = capture_stdout do
904+
Dir.chdir(@test_dir) do
905+
Git::Pkgs::Commands::Where.new(["nonexistent"]).run
906+
end
907+
end
908+
909+
assert_includes output, "not found"
910+
end
911+
912+
def test_where_json_format
913+
output = capture_stdout do
914+
Dir.chdir(@test_dir) do
915+
Git::Pkgs::Commands::Where.new(["rails", "--format=json"]).run
916+
end
917+
end
918+
919+
data = JSON.parse(output)
920+
assert_equal 1, data.length
921+
assert_equal "Gemfile", data.first["path"]
922+
assert data.first["line"].is_a?(Integer)
923+
assert_includes data.first["content"], "rails"
924+
end
925+
926+
def capture_stdout
927+
original = $stdout
928+
$stdout = StringIO.new
929+
yield
930+
$stdout.string
931+
ensure
932+
$stdout = original
933+
end
934+
end
935+
836936
class Git::Pkgs::TestSchemaCommand < Minitest::Test
837937
include TestHelpers
838938

0 commit comments

Comments
 (0)