Skip to content
This repository was archived by the owner on Jan 22, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## [Unreleased]

- `git pkgs where` command to find where a package is declared in manifest files
- `git pkgs diff-driver` command for semantic lockfile diffs in `git diff`

## [0.3.0] - 2026-01-03
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,23 @@ git pkgs show HEAD~5 # relative ref

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

### Find where a package is declared

```bash
git pkgs where rails # find in manifest files
git pkgs where lodash -C 2 # show 2 lines of context
git pkgs where express --ecosystem=npm
```

Shows which manifest files declare a package and the exact line:

```
Gemfile:5:gem "rails", "~> 7.0"
Gemfile.lock:142: rails (7.0.8)
```

Like `grep` but scoped to manifest files that git-pkgs knows about.

### List commits with dependency changes

```bash
Expand Down
1 change: 1 addition & 0 deletions lib/git/pkgs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
require_relative "pkgs/commands/branch"
require_relative "pkgs/commands/search"
require_relative "pkgs/commands/show"
require_relative "pkgs/commands/where"
require_relative "pkgs/commands/log"
require_relative "pkgs/commands/upgrade"
require_relative "pkgs/commands/schema"
Expand Down
3 changes: 2 additions & 1 deletion lib/git/pkgs/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
module Git
module Pkgs
class CLI
COMMANDS = %w[init update hooks info list tree history search why blame stale stats diff branch show log upgrade schema diff-driver].freeze
COMMANDS = %w[init update hooks info list tree history search where why blame stale stats diff branch show log upgrade schema diff-driver].freeze
ALIASES = { "praise" => "blame", "outdated" => "stale" }.freeze

def self.run(args)
Expand Down Expand Up @@ -59,6 +59,7 @@ def print_help
tree Show dependency tree grouped by type
history Show the history of a package
search Find a dependency across all history
where Show where a package appears in manifest files
why Explain why a dependency exists
blame Show who added each dependency
stale Show dependencies that haven't been updated
Expand Down
1 change: 1 addition & 0 deletions lib/git/pkgs/color.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def self.red(text) = colorize(text, :red)
def self.green(text) = colorize(text, :green)
def self.yellow(text) = colorize(text, :yellow)
def self.blue(text) = colorize(text, :blue)
def self.magenta(text) = colorize(text, :magenta)
def self.cyan(text) = colorize(text, :cyan)
def self.bold(text) = colorize(text, :bold)
def self.dim(text) = colorize(text, :dim)
Expand Down
166 changes: 166 additions & 0 deletions lib/git/pkgs/commands/where.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# frozen_string_literal: true

module Git
module Pkgs
module Commands
class Where
include Output

def initialize(args)
@args = args
@options = parse_options
end

def run
name = @args.first

error "Usage: git pkgs where <package-name>" unless name

repo = Repository.new
require_database(repo)

Database.connect(repo.git_dir)

workdir = File.dirname(repo.git_dir)
branch = Models::Branch.find_by(name: @options[:branch] || repo.default_branch)

unless branch
error "Branch not found. Run 'git pkgs init' first."
end

snapshots = Models::DependencySnapshot.current_for_branch(branch)
snapshots = snapshots.where(ecosystem: @options[:ecosystem]) if @options[:ecosystem]

manifest_paths = snapshots.for_package(name).joins(:manifest).pluck("manifests.path").uniq

if manifest_paths.empty?
empty_result "Package '#{name}' not found in current dependencies"
return
end

results = manifest_paths.flat_map do |path|
find_in_manifest(name, File.join(workdir, path), path)
end

if results.empty?
empty_result "Package '#{name}' tracked but not found in current files"
return
end

if @options[:format] == "json"
output_json(results)
else
paginate { output_text(results, name) }
end
end

def find_in_manifest(name, full_path, display_path)
return [] unless File.exist?(full_path)

lines = File.readlines(full_path)
matches = []

lines.each_with_index do |line, idx|
next unless line.include?(name)

match = { path: display_path, line: idx + 1, content: line.rstrip }

if context_lines > 0
match[:before] = context_before(lines, idx)
match[:after] = context_after(lines, idx)
end

matches << match
end

matches
end

def context_lines
@options[:context] || 0
end

def context_before(lines, idx)
start_idx = [0, idx - context_lines].max
(start_idx...idx).map { |i| { line: i + 1, content: lines[i].rstrip } }
end

def context_after(lines, idx)
end_idx = [lines.length - 1, idx + context_lines].min
((idx + 1)..end_idx).map { |i| { line: i + 1, content: lines[i].rstrip } }
end

def output_text(results, name)
results.each_with_index do |result, i|
puts "--" if i > 0 && context_lines > 0

result[:before]&.each do |ctx|
puts format_context_line(result[:path], ctx[:line], ctx[:content])
end

puts format_match_line(result[:path], result[:line], result[:content], name)

result[:after]&.each do |ctx|
puts format_context_line(result[:path], ctx[:line], ctx[:content])
end
end
end

def format_match_line(path, line_num, content, name)
path_str = Color.magenta(path)
line_str = Color.green(line_num.to_s)
highlighted = content.gsub(name, Color.red(name))
"#{path_str}:#{line_str}:#{highlighted}"
end

def format_context_line(path, line_num, content)
path_str = Color.magenta(path)
line_str = Color.green(line_num.to_s)
content_str = Color.dim(content)
"#{path_str}-#{line_str}-#{content_str}"
end

def output_json(results)
require "json"
puts JSON.pretty_generate(results)
end

def parse_options
options = {}

parser = OptionParser.new do |opts|
opts.banner = "Usage: git pkgs where <package-name> [options]"

opts.on("-b", "--branch=NAME", "Branch to search (default: current)") do |v|
options[:branch] = v
end

opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
options[:ecosystem] = v
end

opts.on("-C", "--context=NUM", Integer, "Show NUM lines of context") do |v|
options[:context] = v
end

opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
options[:format] = v
end

opts.on("--no-pager", "Do not pipe output into a pager") do
options[:no_pager] = true
end

opts.on("-h", "--help", "Show this help") do
puts opts
exit
end
end

parser.parse!(@args)
options
end
end
end
end
end
100 changes: 100 additions & 0 deletions test/git/pkgs/test_cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,106 @@ def capture_stdout
end
end

class Git::Pkgs::TestWhereCommand < Minitest::Test
include TestHelpers

def setup
create_test_repo
add_file("Gemfile", sample_gemfile({ "rails" => "~> 7.0", "puma" => "~> 5.0" }))
commit("Add dependencies")
@git_dir = File.join(@test_dir, ".git")
Git::Pkgs::Database.connect(@git_dir)
Git::Pkgs::Database.create_schema

# Create branch and snapshot
repo = Git::Pkgs::Repository.new(@test_dir)
Git::Pkgs::Models::Branch.create!(name: repo.default_branch, last_analyzed_sha: repo.head_sha)
rugged_commit = repo.lookup(repo.head_sha)
commit_record = Git::Pkgs::Models::Commit.find_or_create_from_rugged(rugged_commit)

manifest = Git::Pkgs::Models::Manifest.create!(
path: "Gemfile",
ecosystem: "rubygems",
kind: "manifest"
)

Git::Pkgs::Models::DependencySnapshot.create!(
commit: commit_record,
manifest: manifest,
name: "rails",
ecosystem: "rubygems",
requirement: "~> 7.0"
)

Git::Pkgs::Models::DependencySnapshot.create!(
commit: commit_record,
manifest: manifest,
name: "puma",
ecosystem: "rubygems",
requirement: "~> 5.0"
)
end

def teardown
cleanup_test_repo
end

def test_where_finds_package_in_manifest
output = capture_stdout do
Dir.chdir(@test_dir) do
Git::Pkgs::Commands::Where.new(["rails"]).run
end
end

assert_includes output, "Gemfile"
assert_includes output, "rails"
end

def test_where_shows_line_number
output = capture_stdout do
Dir.chdir(@test_dir) do
Git::Pkgs::Commands::Where.new(["rails"]).run
end
end

# Output format: path:line:content
assert_match(/Gemfile:\d+:.*rails/, output)
end

def test_where_not_found
output = capture_stdout do
Dir.chdir(@test_dir) do
Git::Pkgs::Commands::Where.new(["nonexistent"]).run
end
end

assert_includes output, "not found"
end

def test_where_json_format
output = capture_stdout do
Dir.chdir(@test_dir) do
Git::Pkgs::Commands::Where.new(["rails", "--format=json"]).run
end
end

data = JSON.parse(output)
assert_equal 1, data.length
assert_equal "Gemfile", data.first["path"]
assert data.first["line"].is_a?(Integer)
assert_includes data.first["content"], "rails"
end

def capture_stdout
original = $stdout
$stdout = StringIO.new
yield
$stdout.string
ensure
$stdout = original
end
end

class Git::Pkgs::TestSchemaCommand < Minitest::Test
include TestHelpers

Expand Down