diff --git a/CHANGELOG.md b/CHANGELOG.md index 05e7445..d571541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 08f96e4..5c1c116 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/git/pkgs.rb b/lib/git/pkgs.rb index 06b3e32..459fc3a 100644 --- a/lib/git/pkgs.rb +++ b/lib/git/pkgs.rb @@ -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" diff --git a/lib/git/pkgs/cli.rb b/lib/git/pkgs/cli.rb index f0d09aa..7aa4c17 100644 --- a/lib/git/pkgs/cli.rb +++ b/lib/git/pkgs/cli.rb @@ -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) @@ -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 diff --git a/lib/git/pkgs/color.rb b/lib/git/pkgs/color.rb index 2a04f60..45c0d84 100644 --- a/lib/git/pkgs/color.rb +++ b/lib/git/pkgs/color.rb @@ -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) diff --git a/lib/git/pkgs/commands/where.rb b/lib/git/pkgs/commands/where.rb new file mode 100644 index 0000000..572f5bd --- /dev/null +++ b/lib/git/pkgs/commands/where.rb @@ -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 " 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 [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 diff --git a/test/git/pkgs/test_cli.rb b/test/git/pkgs/test_cli.rb index 1bf8faf..87bf2b8 100644 --- a/test/git/pkgs/test_cli.rb +++ b/test/git/pkgs/test_cli.rb @@ -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