git-pkgs 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class Completions
7
+ COMMANDS = CLI::COMMANDS
8
+ SUBCOMMAND_OPTIONS = {
9
+ "hooks" => %w[--install --uninstall],
10
+ "branch" => %w[--add --remove --list],
11
+ "diff" => %w[--format],
12
+ "list" => %w[--format --type],
13
+ "tree" => %w[--format],
14
+ "history" => %w[--format --limit],
15
+ "search" => %w[--format --limit],
16
+ "blame" => %w[--format],
17
+ "stale" => %w[--days --format],
18
+ "stats" => %w[--format],
19
+ "log" => %w[--limit --format],
20
+ "show" => %w[--format],
21
+ "where" => %w[--format],
22
+ "why" => %w[--format]
23
+ }.freeze
24
+
25
+ BASH_SCRIPT = <<~'BASH'
26
+ _git_pkgs() {
27
+ local cur prev commands
28
+ COMPREPLY=()
29
+ cur="${COMP_WORDS[COMP_CWORD]}"
30
+ prev="${COMP_WORDS[COMP_CWORD-1]}"
31
+
32
+ commands="init update hooks info list tree history search where why blame stale stats diff branch show log upgrade schema completions"
33
+
34
+ if [[ ${COMP_CWORD} -eq 2 && ${COMP_WORDS[1]} == "pkgs" ]]; then
35
+ COMPREPLY=( $(compgen -W "${commands}" -- ${cur}) )
36
+ return 0
37
+ fi
38
+
39
+ if [[ ${COMP_CWORD} -eq 1 ]]; then
40
+ COMPREPLY=( $(compgen -W "${commands}" -- ${cur}) )
41
+ return 0
42
+ fi
43
+
44
+ case "${prev}" in
45
+ hooks)
46
+ COMPREPLY=( $(compgen -W "--install --uninstall --help" -- ${cur}) )
47
+ ;;
48
+ branch)
49
+ COMPREPLY=( $(compgen -W "--add --remove --list --help" -- ${cur}) )
50
+ ;;
51
+ completions)
52
+ COMPREPLY=( $(compgen -W "bash zsh install --help" -- ${cur}) )
53
+ ;;
54
+ diff|list|tree|history|search|blame|stale|stats|log|show|where|why)
55
+ COMPREPLY=( $(compgen -W "--format --help" -- ${cur}) )
56
+ ;;
57
+ esac
58
+
59
+ return 0
60
+ }
61
+
62
+ # Support both 'git pkgs' and 'git-pkgs' invocations
63
+ complete -F _git_pkgs git-pkgs
64
+
65
+ # For 'git pkgs' subcommand completion
66
+ if declare -F _git >/dev/null 2>&1; then
67
+ _git_pkgs_git_wrapper() {
68
+ if [[ ${COMP_WORDS[1]} == "pkgs" ]]; then
69
+ _git_pkgs
70
+ fi
71
+ }
72
+ fi
73
+ BASH
74
+
75
+ ZSH_SCRIPT = <<~'ZSH'
76
+ #compdef git-pkgs
77
+
78
+ _git-pkgs() {
79
+ local -a commands
80
+ commands=(
81
+ 'init:Initialize the package database'
82
+ 'update:Update the database with new commits'
83
+ 'hooks:Manage git hooks for auto-updating'
84
+ 'info:Show database size and row counts'
85
+ 'branch:Manage tracked branches'
86
+ 'list:List dependencies at a commit'
87
+ 'tree:Show dependency tree grouped by type'
88
+ 'history:Show the history of a package'
89
+ 'search:Find a dependency across all history'
90
+ 'where:Show where a package appears in manifest files'
91
+ 'why:Explain why a dependency exists'
92
+ 'blame:Show who added each dependency'
93
+ 'stale:Show dependencies that have not been updated'
94
+ 'stats:Show dependency statistics'
95
+ 'diff:Show dependency changes between commits'
96
+ 'show:Show dependency changes in a commit'
97
+ 'log:List commits with dependency changes'
98
+ 'upgrade:Upgrade database after git-pkgs update'
99
+ 'schema:Show database schema'
100
+ 'completions:Generate shell completions'
101
+ )
102
+
103
+ _arguments -C \
104
+ '1: :->command' \
105
+ '*:: :->args'
106
+
107
+ case $state in
108
+ command)
109
+ _describe -t commands 'git-pkgs commands' commands
110
+ ;;
111
+ args)
112
+ case $words[1] in
113
+ hooks)
114
+ _arguments \
115
+ '--install[Install git hooks]' \
116
+ '--uninstall[Remove git hooks]' \
117
+ '--help[Show help]'
118
+ ;;
119
+ branch)
120
+ _arguments \
121
+ '--add[Add a branch to track]' \
122
+ '--remove[Remove a tracked branch]' \
123
+ '--list[List tracked branches]' \
124
+ '--help[Show help]'
125
+ ;;
126
+ completions)
127
+ _arguments '1:shell:(bash zsh install)'
128
+ ;;
129
+ diff|list|tree|history|search|blame|stale|stats|log|show|where|why)
130
+ _arguments \
131
+ '--format[Output format]:format:(table json csv)' \
132
+ '--help[Show help]'
133
+ ;;
134
+ esac
135
+ ;;
136
+ esac
137
+ }
138
+
139
+ _git-pkgs "$@"
140
+ ZSH
141
+
142
+ def initialize(args)
143
+ @args = args
144
+ end
145
+
146
+ def run
147
+ shell = @args.first
148
+
149
+ case shell
150
+ when "bash"
151
+ puts BASH_SCRIPT
152
+ when "zsh"
153
+ puts ZSH_SCRIPT
154
+ when "install"
155
+ install_completions
156
+ when "-h", "--help", nil
157
+ print_help
158
+ else
159
+ $stderr.puts "Unknown shell: #{shell}"
160
+ $stderr.puts "Supported: bash, zsh, install"
161
+ exit 1
162
+ end
163
+ end
164
+
165
+ def install_completions
166
+ shell = detect_shell
167
+
168
+ case shell
169
+ when "zsh"
170
+ install_zsh_completions
171
+ when "bash"
172
+ install_bash_completions
173
+ else
174
+ $stderr.puts "Could not detect shell. Please run one of:"
175
+ $stderr.puts " eval \"$(git pkgs completions bash)\""
176
+ $stderr.puts " eval \"$(git pkgs completions zsh)\""
177
+ exit 1
178
+ end
179
+ end
180
+
181
+ def detect_shell
182
+ shell_env = ENV["SHELL"] || ""
183
+ if shell_env.include?("zsh")
184
+ "zsh"
185
+ elsif shell_env.include?("bash")
186
+ "bash"
187
+ end
188
+ end
189
+
190
+ def install_bash_completions
191
+ dir = File.expand_path("~/.local/share/bash-completion/completions")
192
+ FileUtils.mkdir_p(dir)
193
+ path = File.join(dir, "git-pkgs")
194
+ File.write(path, BASH_SCRIPT)
195
+ puts "Installed bash completions to #{path}"
196
+ puts "Restart your shell or run: source #{path}"
197
+ end
198
+
199
+ def install_zsh_completions
200
+ dir = File.expand_path("~/.zsh/completions")
201
+ FileUtils.mkdir_p(dir)
202
+ path = File.join(dir, "_git-pkgs")
203
+ File.write(path, ZSH_SCRIPT)
204
+ puts "Installed zsh completions to #{path}"
205
+ puts ""
206
+ puts "Add to your ~/.zshrc if not already present:"
207
+ puts " fpath=(~/.zsh/completions $fpath)"
208
+ puts " autoload -Uz compinit && compinit"
209
+ puts ""
210
+ puts "Then restart your shell or run: source ~/.zshrc"
211
+ end
212
+
213
+ def print_help
214
+ puts <<~HELP
215
+ Usage: git pkgs completions <shell>
216
+
217
+ Generate shell completion scripts.
218
+
219
+ Shells:
220
+ bash Output bash completion script
221
+ zsh Output zsh completion script
222
+ install Auto-install completions for your shell
223
+
224
+ Examples:
225
+ git pkgs completions bash > ~/.local/share/bash-completion/completions/git-pkgs
226
+ git pkgs completions zsh > ~/.zsh/completions/_git-pkgs
227
+ eval "$(git pkgs completions bash)"
228
+ git pkgs completions install
229
+ HELP
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end
@@ -17,23 +17,24 @@ module Git
17
17
 
18
18
  Database.connect(repo.git_dir)
19
19
 
20
- from_ref = @options[:from]
21
- to_ref = @options[:to] || "HEAD"
20
+ from_ref, to_ref = parse_range_argument
21
+ from_ref ||= @options[:from]
22
+ to_ref ||= @options[:to] || "HEAD"
22
23
 
23
- error "Usage: git pkgs diff --from=REF [--to=REF]" unless from_ref
24
+ error "Usage: git pkgs diff <commit>..<commit> or git pkgs diff --from=REF [--to=REF]" unless from_ref
24
25
 
25
26
  # Resolve git refs (like HEAD~10) to SHAs
26
27
  from_sha = repo.rev_parse(from_ref)
27
28
  to_sha = repo.rev_parse(to_ref)
28
29
 
29
- error "Could not resolve '#{from_ref}'" unless from_sha
30
- error "Could not resolve '#{to_ref}'" unless to_sha
30
+ error "Could not resolve '#{from_ref}'. Check that the ref exists." unless from_sha
31
+ error "Could not resolve '#{to_ref}'. Check that the ref exists." unless to_sha
31
32
 
32
- from_commit = find_or_create_commit(repo, from_sha)
33
- to_commit = find_or_create_commit(repo, to_sha)
33
+ from_commit = Models::Commit.find_or_create_from_repo(repo, from_sha)
34
+ to_commit = Models::Commit.find_or_create_from_repo(repo, to_sha)
34
35
 
35
- error "Commit '#{from_sha[0..7]}' not found" unless from_commit
36
- error "Commit '#{to_sha[0..7]}' not found" unless to_commit
36
+ error "Commit '#{from_sha[0..7]}' not in database. Run 'git pkgs update' to index new commits." unless from_commit
37
+ error "Commit '#{to_sha[0..7]}' not in database. Run 'git pkgs update' to index new commits." unless to_commit
37
38
 
38
39
  # Get all changes between the two commits
39
40
  changes = Models::DependencyChange
@@ -98,34 +99,36 @@ module Git
98
99
  puts "Summary: #{added_count} #{removed_count} #{modified_count}"
99
100
  end
100
101
 
101
- def find_or_create_commit(repo, sha)
102
- commit = Models::Commit.find_by(sha: sha) ||
103
- Models::Commit.where("sha LIKE ?", "#{sha}%").first
104
- return commit if commit
105
-
106
- # Lazily insert commit if it exists in git but not in database
107
- rugged_commit = repo.lookup(sha)
108
- return nil unless rugged_commit
109
-
110
- Models::Commit.create!(
111
- sha: rugged_commit.oid,
112
- message: rugged_commit.message,
113
- author_name: rugged_commit.author[:name],
114
- author_email: rugged_commit.author[:email],
115
- committed_at: rugged_commit.time,
116
- has_dependency_changes: false
117
- )
118
- rescue Rugged::OdbError
119
- nil
102
+ def parse_range_argument
103
+ return [nil, nil] if @args.empty?
104
+
105
+ arg = @args.first
106
+ return [nil, nil] if arg.start_with?("-")
107
+
108
+ if arg.include?("..")
109
+ @args.shift
110
+ parts = arg.split("..", 2)
111
+ [parts[0], parts[1].empty? ? "HEAD" : parts[1]]
112
+ else
113
+ # Single ref means "from that ref to HEAD"
114
+ @args.shift
115
+ [arg, "HEAD"]
116
+ end
120
117
  end
121
118
 
122
119
  def parse_options
123
120
  options = {}
124
121
 
125
122
  parser = OptionParser.new do |opts|
126
- opts.banner = "Usage: git pkgs diff --from=REF [--to=REF] [options]"
127
-
128
- opts.on("-f", "--from=REF", "Start commit (required)") do |v|
123
+ opts.banner = "Usage: git pkgs diff [<from>..<to>] [options]"
124
+ opts.separator ""
125
+ opts.separator "Examples:"
126
+ opts.separator " git pkgs diff main..feature"
127
+ opts.separator " git pkgs diff HEAD~10"
128
+ opts.separator " git pkgs diff --from=v1.0 --to=v2.0"
129
+ opts.separator ""
130
+
131
+ opts.on("-f", "--from=REF", "Start commit") do |v|
129
132
  options[:from] = v
130
133
  end
131
134
 
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bibliothecary"
4
+
5
+ module Git
6
+ module Pkgs
7
+ module Commands
8
+ class DiffDriver
9
+ include Output
10
+
11
+ # Only lockfiles - manifests are human-readable and diff fine normally
12
+ LOCKFILE_PATTERNS = %w[
13
+ Brewfile.lock.json
14
+ Cargo.lock
15
+ Cartfile.resolved
16
+ Gemfile.lock
17
+ Gopkg.lock
18
+ Package.resolved
19
+ Pipfile.lock
20
+ Podfile.lock
21
+ Project.lock.json
22
+ bun.lock
23
+ composer.lock
24
+ gems.locked
25
+ glide.lock
26
+ go.sum
27
+ mix.lock
28
+ npm-shrinkwrap.json
29
+ package-lock.json
30
+ packages.lock.json
31
+ paket.lock
32
+ pnpm-lock.yaml
33
+ poetry.lock
34
+ project.assets.json
35
+ pubspec.lock
36
+ pylock.toml
37
+ shard.lock
38
+ uv.lock
39
+ yarn.lock
40
+ ].freeze
41
+
42
+ def initialize(args)
43
+ @args = args
44
+ @options = parse_options
45
+ Config.configure_bibliothecary
46
+ end
47
+
48
+ def run
49
+ if @options[:install]
50
+ install_driver
51
+ return
52
+ end
53
+
54
+ if @options[:uninstall]
55
+ uninstall_driver
56
+ return
57
+ end
58
+
59
+ # textconv mode: single file argument, output dependency list
60
+ if @args.length == 1
61
+ output_textconv(@args[0])
62
+ return
63
+ end
64
+
65
+ error "Usage: git pkgs diff-driver <file>"
66
+ end
67
+
68
+ def output_textconv(file_path)
69
+ content = read_file(file_path)
70
+ deps = parse_deps(file_path, content)
71
+
72
+ # Output sorted dependency list for git to diff
73
+ deps.keys.sort.each do |name|
74
+ dep = deps[name]
75
+ # Only show type if it's not runtime (the default)
76
+ type_suffix = dep[:type] && dep[:type] != "runtime" ? " [#{dep[:type]}]" : ""
77
+ puts "#{name} #{dep[:requirement]}#{type_suffix}"
78
+ end
79
+ end
80
+
81
+ def install_driver
82
+ # Set up git config for textconv
83
+ system("git", "config", "diff.pkgs.textconv", "git-pkgs diff-driver")
84
+
85
+ # Add to .gitattributes
86
+ gitattributes_path = File.join(Dir.pwd, ".gitattributes")
87
+ existing = File.exist?(gitattributes_path) ? File.read(gitattributes_path) : ""
88
+
89
+ new_entries = []
90
+ LOCKFILE_PATTERNS.each do |pattern|
91
+ entry = "#{pattern} diff=pkgs"
92
+ new_entries << entry unless existing.include?(entry)
93
+ end
94
+
95
+ if new_entries.any?
96
+ File.open(gitattributes_path, "a") do |f|
97
+ f.puts unless existing.end_with?("\n") || existing.empty?
98
+ f.puts "# git-pkgs textconv for lockfiles"
99
+ new_entries.each { |entry| f.puts entry }
100
+ end
101
+ end
102
+
103
+ info "Installed textconv driver for lockfiles."
104
+ info " git config: diff.pkgs.textconv = git-pkgs diff-driver"
105
+ info " .gitattributes: #{new_entries.count} lockfile patterns added"
106
+ info ""
107
+ info "Now 'git diff' on lockfiles shows dependency changes."
108
+ info "Use 'git diff --no-textconv' to see raw diff."
109
+ end
110
+
111
+ def uninstall_driver
112
+ system("git", "config", "--unset", "diff.pkgs.textconv")
113
+
114
+ gitattributes_path = File.join(Dir.pwd, ".gitattributes")
115
+ if File.exist?(gitattributes_path)
116
+ lines = File.readlines(gitattributes_path)
117
+ lines.reject! { |line| line.include?("diff=pkgs") || line.include?("# git-pkgs") }
118
+ File.write(gitattributes_path, lines.join)
119
+ end
120
+
121
+ info "Uninstalled diff driver."
122
+ end
123
+
124
+ def read_file(path)
125
+ return "" if path == "/dev/null"
126
+ return "" unless File.exist?(path)
127
+
128
+ File.read(path)
129
+ end
130
+
131
+ def parse_deps(path, content)
132
+ return {} if content.empty?
133
+
134
+ result = Bibliothecary.analyse_file(path, content).first
135
+ return {} unless result
136
+ return {} if Config.filter_ecosystem?(result[:platform])
137
+
138
+ result[:dependencies].map { |d| [d[:name], d] }.to_h
139
+ rescue StandardError
140
+ {}
141
+ end
142
+
143
+ def parse_options
144
+ options = {}
145
+
146
+ parser = OptionParser.new do |opts|
147
+ opts.banner = "Usage: git pkgs diff-driver <file>"
148
+ opts.separator ""
149
+ opts.separator "Outputs dependency list for git textconv diffing."
150
+
151
+ opts.on("--install", "Install textconv driver for lockfiles") do
152
+ options[:install] = true
153
+ end
154
+
155
+ opts.on("--uninstall", "Uninstall textconv driver") do
156
+ options[:uninstall] = true
157
+ end
158
+
159
+ opts.on("-h", "--help", "Show this help") do
160
+ puts opts
161
+ exit
162
+ end
163
+ end
164
+
165
+ parser.parse!(@args)
166
+ options
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "time"
4
-
5
3
  module Git
6
4
  module Pkgs
7
5
  module Commands
@@ -159,11 +157,6 @@ module Git
159
157
  options
160
158
  end
161
159
 
162
- def parse_time(str)
163
- Time.parse(str)
164
- rescue ArgumentError
165
- error "Invalid date format: #{str}"
166
- end
167
160
  end
168
161
  end
169
162
  end
@@ -40,7 +40,7 @@ module Git
40
40
  if File.exist?(hook_path)
41
41
  content = File.read(hook_path)
42
42
  if content.include?("git-pkgs")
43
- puts "Hook #{hook_name} already contains git-pkgs"
43
+ info "Hook #{hook_name} already contains git-pkgs"
44
44
  next
45
45
  end
46
46
 
@@ -48,15 +48,15 @@ module Git
48
48
  f.puts "\n# git-pkgs auto-update"
49
49
  f.puts "git pkgs update 2>/dev/null || true"
50
50
  end
51
- puts "Appended git-pkgs to existing #{hook_name} hook"
51
+ info "Appended git-pkgs to existing #{hook_name} hook"
52
52
  else
53
53
  File.write(hook_path, HOOK_SCRIPT)
54
54
  File.chmod(0o755, hook_path)
55
- puts "Created #{hook_name} hook"
55
+ info "Created #{hook_name} hook"
56
56
  end
57
57
  end
58
58
 
59
- puts "Hooks installed successfully"
59
+ info "Hooks installed successfully"
60
60
  end
61
61
 
62
62
  def uninstall_hooks(repo)
@@ -70,7 +70,7 @@ module Git
70
70
 
71
71
  if content.strip == HOOK_SCRIPT.strip
72
72
  File.delete(hook_path)
73
- puts "Removed #{hook_name} hook"
73
+ info "Removed #{hook_name} hook"
74
74
  elsif content.include?("git-pkgs")
75
75
  new_content = content.lines.reject { |line|
76
76
  line.include?("git-pkgs") || line.include?("git pkgs")
@@ -79,15 +79,15 @@ module Git
79
79
 
80
80
  if new_content.strip.empty? || new_content.strip == "#!/bin/sh"
81
81
  File.delete(hook_path)
82
- puts "Removed #{hook_name} hook"
82
+ info "Removed #{hook_name} hook"
83
83
  else
84
84
  File.write(hook_path, new_content)
85
- puts "Removed git-pkgs from #{hook_name} hook"
85
+ info "Removed git-pkgs from #{hook_name} hook"
86
86
  end
87
87
  end
88
88
  end
89
89
 
90
- puts "Hooks uninstalled successfully"
90
+ info "Hooks uninstalled successfully"
91
91
  end
92
92
 
93
93
  def show_status(repo)
@@ -12,6 +12,11 @@ module Git
12
12
  end
13
13
 
14
14
  def run
15
+ if @options[:ecosystems]
16
+ output_ecosystems
17
+ return
18
+ end
19
+
15
20
  repo = Repository.new
16
21
  require_database(repo)
17
22
 
@@ -69,10 +74,69 @@ module Git
69
74
  puts " Commits with snapshots: #{snapshot_commits}"
70
75
  if total_dep_commits > 0
71
76
  ratio = (snapshot_commits.to_f / total_dep_commits * 100).round(1)
72
- puts " Coverage: #{ratio}% (1 snapshot per ~#{(total_dep_commits.to_f / snapshot_commits).round(0)} changes)"
77
+ if snapshot_commits > 0
78
+ puts " Coverage: #{ratio}% (1 snapshot per ~#{(total_dep_commits / snapshot_commits)} changes)"
79
+ else
80
+ puts " Coverage: #{ratio}%"
81
+ end
73
82
  end
74
83
  end
75
84
 
85
+ def output_ecosystems
86
+ require "bibliothecary"
87
+
88
+ all_ecosystems = Bibliothecary::Parsers.constants.map do |c|
89
+ parser = Bibliothecary::Parsers.const_get(c)
90
+ parser.platform_name if parser.respond_to?(:platform_name)
91
+ end.compact.sort
92
+
93
+ configured = Config.ecosystems
94
+ filtering = configured.any?
95
+
96
+ puts "Available Ecosystems"
97
+ puts "=" * 40
98
+ puts
99
+
100
+ enabled_ecos = []
101
+ disabled_ecos = []
102
+
103
+ all_ecosystems.each do |eco|
104
+ if Config.filter_ecosystem?(eco)
105
+ remote = Config.remote_ecosystem?(eco)
106
+ disabled_ecos << { name: eco, remote: remote }
107
+ else
108
+ enabled_ecos << eco
109
+ end
110
+ end
111
+
112
+ puts "Enabled:"
113
+ if enabled_ecos.any?
114
+ enabled_ecos.each { |eco| puts " #{Color.green(eco)}" }
115
+ else
116
+ puts " (none)"
117
+ end
118
+
119
+ puts
120
+ puts "Disabled:"
121
+ if disabled_ecos.any?
122
+ disabled_ecos.each do |eco|
123
+ suffix = eco[:remote] ? " (remote)" : ""
124
+ puts " #{eco[:name]}#{suffix}"
125
+ end
126
+ else
127
+ puts " (none)"
128
+ end
129
+
130
+ puts
131
+ if filtering
132
+ puts "Filtering: only #{configured.join(', ')}"
133
+ else
134
+ puts "All local ecosystems enabled"
135
+ end
136
+ puts "Remote ecosystems require explicit opt-in"
137
+ puts "Configure with: git config --add pkgs.ecosystems <name>"
138
+ end
139
+
76
140
  def format_size(bytes)
77
141
  units = %w[B KB MB GB]
78
142
  unit_index = 0
@@ -90,7 +154,11 @@ module Git
90
154
  options = {}
91
155
 
92
156
  parser = OptionParser.new do |opts|
93
- opts.banner = "Usage: git pkgs info"
157
+ opts.banner = "Usage: git pkgs info [options]"
158
+
159
+ opts.on("--ecosystems", "Show available ecosystems and filter status") do
160
+ options[:ecosystems] = true
161
+ end
94
162
 
95
163
  opts.on("-h", "--help", "Show this help") do
96
164
  puts opts