git-pkgs 0.2.0 → 0.4.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,169 @@
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
+ end
46
+
47
+ def run
48
+ if @options[:install]
49
+ install_driver
50
+ return
51
+ end
52
+
53
+ if @options[:uninstall]
54
+ uninstall_driver
55
+ return
56
+ end
57
+
58
+ # textconv mode: single file argument, output dependency list
59
+ if @args.length == 1
60
+ output_textconv(@args[0])
61
+ return
62
+ end
63
+
64
+ error "Usage: git pkgs diff-driver <file>"
65
+ end
66
+
67
+ def output_textconv(file_path)
68
+ content = read_file(file_path)
69
+ deps = parse_deps(file_path, content)
70
+
71
+ # Output sorted dependency list for git to diff
72
+ deps.keys.sort.each do |name|
73
+ dep = deps[name]
74
+ # Only show type if it's not runtime (the default)
75
+ type_suffix = dep[:type] && dep[:type] != "runtime" ? " [#{dep[:type]}]" : ""
76
+ puts "#{name} #{dep[:requirement]}#{type_suffix}"
77
+ end
78
+ end
79
+
80
+ def install_driver
81
+ # Set up git config for textconv
82
+ system("git", "config", "diff.pkgs.textconv", "git-pkgs diff-driver")
83
+
84
+ # Add to .gitattributes
85
+ gitattributes_path = File.join(Dir.pwd, ".gitattributes")
86
+ existing = File.exist?(gitattributes_path) ? File.read(gitattributes_path) : ""
87
+
88
+ new_entries = []
89
+ LOCKFILE_PATTERNS.each do |pattern|
90
+ entry = "#{pattern} diff=pkgs"
91
+ new_entries << entry unless existing.include?(entry)
92
+ end
93
+
94
+ if new_entries.any?
95
+ File.open(gitattributes_path, "a") do |f|
96
+ f.puts unless existing.end_with?("\n") || existing.empty?
97
+ f.puts "# git-pkgs textconv for lockfiles"
98
+ new_entries.each { |entry| f.puts entry }
99
+ end
100
+ end
101
+
102
+ puts "Installed textconv driver for lockfiles."
103
+ puts " git config: diff.pkgs.textconv = git-pkgs diff-driver"
104
+ puts " .gitattributes: #{new_entries.count} lockfile patterns added"
105
+ puts
106
+ puts "Now 'git diff' on lockfiles shows dependency changes."
107
+ puts "Use 'git diff --no-textconv' to see raw diff."
108
+ end
109
+
110
+ def uninstall_driver
111
+ system("git", "config", "--unset", "diff.pkgs.textconv")
112
+
113
+ gitattributes_path = File.join(Dir.pwd, ".gitattributes")
114
+ if File.exist?(gitattributes_path)
115
+ lines = File.readlines(gitattributes_path)
116
+ lines.reject! { |line| line.include?("diff=pkgs") || line.include?("# git-pkgs") }
117
+ File.write(gitattributes_path, lines.join)
118
+ end
119
+
120
+ puts "Uninstalled diff driver."
121
+ end
122
+
123
+ def read_file(path)
124
+ return "" if path == "/dev/null"
125
+ return "" unless File.exist?(path)
126
+
127
+ File.read(path)
128
+ end
129
+
130
+ def parse_deps(path, content)
131
+ return {} if content.empty?
132
+
133
+ result = Bibliothecary.analyse_file(path, content).first
134
+ return {} unless result
135
+
136
+ result[:dependencies].map { |d| [d[:name], d] }.to_h
137
+ rescue StandardError
138
+ {}
139
+ end
140
+
141
+ def parse_options
142
+ options = {}
143
+
144
+ parser = OptionParser.new do |opts|
145
+ opts.banner = "Usage: git pkgs diff-driver <file>"
146
+ opts.separator ""
147
+ opts.separator "Outputs dependency list for git textconv diffing."
148
+
149
+ opts.on("--install", "Install textconv driver for lockfiles") do
150
+ options[:install] = true
151
+ end
152
+
153
+ opts.on("--uninstall", "Uninstall textconv driver") do
154
+ options[:uninstall] = true
155
+ end
156
+
157
+ opts.on("-h", "--help", "Show this help") do
158
+ puts opts
159
+ exit
160
+ end
161
+ end
162
+
163
+ parser.parse!(@args)
164
+ options
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -6,6 +6,8 @@ module Git
6
6
  module Pkgs
7
7
  module Commands
8
8
  class History
9
+ include Output
10
+
9
11
  def initialize(args)
10
12
  @args = args
11
13
  @options = parse_options
@@ -15,11 +17,7 @@ module Git
15
17
  package_name = @args.shift
16
18
 
17
19
  repo = Repository.new
18
-
19
- unless Database.exists?(repo.git_dir)
20
- $stderr.puts "Database not initialized. Run 'git pkgs init' first."
21
- exit 1
22
- end
20
+ require_database(repo)
23
21
 
24
22
  Database.connect(repo.git_dir)
25
23
 
@@ -52,18 +50,15 @@ module Git
52
50
  end
53
51
 
54
52
  if changes.empty?
55
- if package_name
56
- puts "No history found for '#{package_name}'"
57
- else
58
- puts "No dependency changes found"
59
- end
53
+ msg = package_name ? "No history found for '#{package_name}'" : "No dependency changes found"
54
+ empty_result msg
60
55
  return
61
56
  end
62
57
 
63
58
  if @options[:format] == "json"
64
59
  output_json(changes)
65
60
  else
66
- output_text(changes, package_name)
61
+ paginate { output_text(changes, package_name) }
67
62
  end
68
63
  end
69
64
 
@@ -81,13 +76,13 @@ module Git
81
76
 
82
77
  case change.change_type
83
78
  when "added"
84
- action = "Added"
79
+ action = Color.green("Added")
85
80
  version_info = change.requirement
86
81
  when "modified"
87
- action = "Updated"
82
+ action = Color.yellow("Updated")
88
83
  version_info = "#{change.previous_requirement} -> #{change.requirement}"
89
84
  when "removed"
90
- action = "Removed"
85
+ action = Color.red("Removed")
91
86
  version_info = change.requirement
92
87
  end
93
88
 
@@ -150,6 +145,10 @@ module Git
150
145
  options[:until] = v
151
146
  end
152
147
 
148
+ opts.on("--no-pager", "Do not pipe output into a pager") do
149
+ options[:no_pager] = true
150
+ end
151
+
153
152
  opts.on("-h", "--help", "Show this help") do
154
153
  puts opts
155
154
  exit
@@ -163,8 +162,7 @@ module Git
163
162
  def parse_time(str)
164
163
  Time.parse(str)
165
164
  rescue ArgumentError
166
- $stderr.puts "Invalid date format: #{str}"
167
- exit 1
165
+ error "Invalid date format: #{str}"
168
166
  end
169
167
  end
170
168
  end
@@ -4,6 +4,8 @@ module Git
4
4
  module Pkgs
5
5
  module Commands
6
6
  class Hooks
7
+ include Output
8
+
7
9
  HOOK_SCRIPT = <<~SCRIPT
8
10
  #!/bin/sh
9
11
  # git-pkgs auto-update hook
@@ -4,6 +4,8 @@ module Git
4
4
  module Pkgs
5
5
  module Commands
6
6
  class Info
7
+ include Output
8
+
7
9
  def initialize(args)
8
10
  @args = args
9
11
  @options = parse_options
@@ -11,11 +13,7 @@ module Git
11
13
 
12
14
  def run
13
15
  repo = Repository.new
14
-
15
- unless Database.exists?(repo.git_dir)
16
- $stderr.puts "Database not initialized. Run 'git pkgs init' first."
17
- exit 1
18
- end
16
+ require_database(repo)
19
17
 
20
18
  db_path = Database.path(repo.git_dir)
21
19
  Database.connect(repo.git_dir)
@@ -71,7 +69,11 @@ module Git
71
69
  puts " Commits with snapshots: #{snapshot_commits}"
72
70
  if total_dep_commits > 0
73
71
  ratio = (snapshot_commits.to_f / total_dep_commits * 100).round(1)
74
- puts " Coverage: #{ratio}% (1 snapshot per ~#{(total_dep_commits.to_f / snapshot_commits).round(0)} changes)"
72
+ if snapshot_commits > 0
73
+ puts " Coverage: #{ratio}% (1 snapshot per ~#{(total_dep_commits / snapshot_commits)} changes)"
74
+ else
75
+ puts " Coverage: #{ratio}%"
76
+ end
75
77
  end
76
78
  end
77
79
 
@@ -4,6 +4,8 @@ module Git
4
4
  module Pkgs
5
5
  module Commands
6
6
  class Init
7
+ include Output
8
+
7
9
  BATCH_SIZE = 100
8
10
  SNAPSHOT_INTERVAL = 20 # Store snapshot every N dependency-changing commits
9
11
 
@@ -15,22 +17,19 @@ module Git
15
17
  def run
16
18
  repo = Repository.new
17
19
 
20
+ branch_name = @options[:branch] || repo.default_branch
21
+ error "Branch '#{branch_name}' not found" unless repo.branch_exists?(branch_name)
22
+
18
23
  if Database.exists?(repo.git_dir) && !@options[:force]
19
24
  puts "Database already exists. Use --force to rebuild."
20
25
  return
21
26
  end
22
27
 
23
28
  Database.drop if @options[:force]
24
- Database.connect(repo.git_dir)
29
+ Database.connect(repo.git_dir, check_version: false)
25
30
  Database.create_schema(with_indexes: false)
26
31
  Database.optimize_for_bulk_writes
27
32
 
28
- branch_name = @options[:branch] || repo.default_branch
29
- unless repo.branch_exists?(branch_name)
30
- $stderr.puts "Branch '#{branch_name}' not found"
31
- exit 1
32
- end
33
-
34
33
  branch = Models::Branch.find_or_create(branch_name)
35
34
  analyzer = Analyzer.new(repo)
36
35
 
@@ -74,6 +73,7 @@ module Git
74
73
  dependency_commit_count = 0
75
74
  snapshots_stored = 0
76
75
  processed = 0
76
+ last_processed_sha = nil
77
77
 
78
78
  flush = lambda do
79
79
  return if pending_commits.empty?
@@ -161,6 +161,8 @@ module Git
161
161
  position: processed
162
162
  }
163
163
 
164
+ last_processed_sha = rugged_commit.oid
165
+
164
166
  if has_changes
165
167
  dependency_commit_count += 1
166
168
 
@@ -207,13 +209,12 @@ module Git
207
209
  flush.call if pending_commits.size >= BATCH_SIZE
208
210
  end
209
211
 
210
- # Always store final snapshot for HEAD
211
- if snapshot.any?
212
- last_sha = commits.last&.oid
213
- if last_sha && !pending_snapshots.any? { |s| s[:sha] == last_sha }
212
+ # Always store final snapshot for the last processed commit
213
+ if snapshot.any? && last_processed_sha
214
+ unless pending_snapshots.any? { |s| s[:sha] == last_processed_sha }
214
215
  snapshot.each do |(manifest_path, name), dep_info|
215
216
  pending_snapshots << {
216
- sha: last_sha,
217
+ sha: last_processed_sha,
217
218
  manifest_path: manifest_path,
218
219
  name: name,
219
220
  ecosystem: dep_info[:ecosystem],
@@ -4,6 +4,8 @@ module Git
4
4
  module Pkgs
5
5
  module Commands
6
6
  class List
7
+ include Output
8
+
7
9
  def initialize(args)
8
10
  @args = args
9
11
  @options = parse_options
@@ -11,26 +13,19 @@ module Git
11
13
 
12
14
  def run
13
15
  repo = Repository.new
14
-
15
- unless Database.exists?(repo.git_dir)
16
- $stderr.puts "Database not initialized. Run 'git pkgs init' first."
17
- exit 1
18
- end
16
+ require_database(repo)
19
17
 
20
18
  Database.connect(repo.git_dir)
21
19
 
22
20
  commit_sha = @options[:commit] || repo.head_sha
23
21
  target_commit = Models::Commit.find_by(sha: commit_sha)
24
22
 
25
- unless target_commit
26
- $stderr.puts "Commit #{commit_sha[0, 7]} not found in database"
27
- exit 1
28
- end
23
+ error "Commit #{commit_sha[0, 7]} not found in database" unless target_commit
29
24
 
30
25
  deps = compute_dependencies_at_commit(target_commit, repo)
31
26
 
32
27
  if deps.empty?
33
- puts "No dependencies found"
28
+ empty_result "No dependencies found"
34
29
  return
35
30
  end
36
31
 
@@ -47,16 +42,20 @@ module Git
47
42
  require "json"
48
43
  puts JSON.pretty_generate(deps)
49
44
  else
50
- grouped = deps.group_by { |d| [d[:manifest_path], d[:ecosystem]] }
45
+ paginate { output_text(deps) }
46
+ end
47
+ end
51
48
 
52
- grouped.each do |(path, platform), manifest_deps|
53
- puts "#{path} (#{platform}):"
54
- manifest_deps.sort_by { |d| d[:name] }.each do |dep|
55
- type_suffix = dep[:dependency_type] ? " [#{dep[:dependency_type]}]" : ""
56
- puts " #{dep[:name]} #{dep[:requirement]}#{type_suffix}"
57
- end
58
- puts
49
+ def output_text(deps)
50
+ grouped = deps.group_by { |d| [d[:manifest_path], d[:ecosystem]] }
51
+
52
+ grouped.each do |(path, platform), manifest_deps|
53
+ puts "#{path} (#{platform}):"
54
+ manifest_deps.sort_by { |d| d[:name] }.each do |dep|
55
+ type_suffix = dep[:dependency_type] ? " [#{dep[:dependency_type]}]" : ""
56
+ puts " #{dep[:name]} #{dep[:requirement]}#{type_suffix}"
59
57
  end
58
+ puts
60
59
  end
61
60
  end
62
61
 
@@ -144,6 +143,10 @@ module Git
144
143
  options[:format] = v
145
144
  end
146
145
 
146
+ opts.on("--no-pager", "Do not pipe output into a pager") do
147
+ options[:no_pager] = true
148
+ end
149
+
147
150
  opts.on("-h", "--help", "Show this help") do
148
151
  puts opts
149
152
  exit
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Git
6
+ module Pkgs
7
+ module Commands
8
+ class Log
9
+ include Output
10
+
11
+ def initialize(args)
12
+ @args = args
13
+ @options = parse_options
14
+ end
15
+
16
+ def run
17
+ repo = Repository.new
18
+ require_database(repo)
19
+
20
+ Database.connect(repo.git_dir)
21
+
22
+ commits = Models::Commit
23
+ .where(has_dependency_changes: true)
24
+ .order(committed_at: :desc)
25
+
26
+ commits = commits.where("author_name LIKE ? OR author_email LIKE ?",
27
+ "%#{@options[:author]}%", "%#{@options[:author]}%") if @options[:author]
28
+ commits = commits.where("committed_at >= ?", parse_time(@options[:since])) if @options[:since]
29
+ commits = commits.where("committed_at <= ?", parse_time(@options[:until])) if @options[:until]
30
+
31
+ commits = commits.limit(@options[:limit] || 20)
32
+
33
+ if commits.empty?
34
+ empty_result "No commits with dependency changes found"
35
+ return
36
+ end
37
+
38
+ if @options[:format] == "json"
39
+ output_json(commits)
40
+ else
41
+ paginate { output_text(commits) }
42
+ end
43
+ end
44
+
45
+ def output_text(commits)
46
+ commits.each do |commit|
47
+ changes = commit.dependency_changes
48
+ changes = changes.where(ecosystem: @options[:ecosystem]) if @options[:ecosystem]
49
+ next if changes.empty?
50
+
51
+ puts "#{commit.short_sha} #{commit.message&.lines&.first&.strip}"
52
+ puts "Author: #{commit.author_name} <#{commit.author_email}>"
53
+ puts "Date: #{commit.committed_at.strftime("%Y-%m-%d")}"
54
+ puts
55
+
56
+ added = changes.select { |c| c.change_type == "added" }
57
+ modified = changes.select { |c| c.change_type == "modified" }
58
+ removed = changes.select { |c| c.change_type == "removed" }
59
+
60
+ added.each do |change|
61
+ puts " + #{change.name} #{change.requirement}"
62
+ end
63
+
64
+ modified.each do |change|
65
+ puts " ~ #{change.name} #{change.previous_requirement} -> #{change.requirement}"
66
+ end
67
+
68
+ removed.each do |change|
69
+ puts " - #{change.name}"
70
+ end
71
+
72
+ puts
73
+ end
74
+ end
75
+
76
+ def output_json(commits)
77
+ require "json"
78
+
79
+ data = commits.map do |commit|
80
+ changes = commit.dependency_changes
81
+ changes = changes.where(ecosystem: @options[:ecosystem]) if @options[:ecosystem]
82
+
83
+ {
84
+ sha: commit.sha,
85
+ short_sha: commit.short_sha,
86
+ message: commit.message&.lines&.first&.strip,
87
+ author_name: commit.author_name,
88
+ author_email: commit.author_email,
89
+ date: commit.committed_at.iso8601,
90
+ changes: changes.map do |change|
91
+ {
92
+ name: change.name,
93
+ change_type: change.change_type,
94
+ requirement: change.requirement,
95
+ previous_requirement: change.previous_requirement,
96
+ ecosystem: change.ecosystem
97
+ }
98
+ end
99
+ }
100
+ end
101
+
102
+ puts JSON.pretty_generate(data)
103
+ end
104
+
105
+ def parse_time(str)
106
+ Time.parse(str)
107
+ rescue ArgumentError
108
+ error "Invalid date format: #{str}"
109
+ end
110
+
111
+ def parse_options
112
+ options = {}
113
+
114
+ parser = OptionParser.new do |opts|
115
+ opts.banner = "Usage: git pkgs log [options]"
116
+
117
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
118
+ options[:ecosystem] = v
119
+ end
120
+
121
+ opts.on("--author=NAME", "Filter by author name or email") do |v|
122
+ options[:author] = v
123
+ end
124
+
125
+ opts.on("--since=DATE", "Show commits after date") do |v|
126
+ options[:since] = v
127
+ end
128
+
129
+ opts.on("--until=DATE", "Show commits before date") do |v|
130
+ options[:until] = v
131
+ end
132
+
133
+ opts.on("-n", "--limit=N", Integer, "Limit commits (default: 20)") do |v|
134
+ options[:limit] = v
135
+ end
136
+
137
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
138
+ options[:format] = v
139
+ end
140
+
141
+ opts.on("--no-pager", "Do not pipe output into a pager") do
142
+ options[:no_pager] = true
143
+ end
144
+
145
+ opts.on("-h", "--help", "Show this help") do
146
+ puts opts
147
+ exit
148
+ end
149
+ end
150
+
151
+ parser.parse!(@args)
152
+ options
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end