Skip to content

Commit

Permalink
feat: add wrap command to wrap gems
Browse files Browse the repository at this point in the history
  • Loading branch information
ronaldtse committed Jul 22, 2024
1 parent 3bba1a0 commit a65a841
Show file tree
Hide file tree
Showing 11 changed files with 539 additions and 205 deletions.
101 changes: 79 additions & 22 deletions README.adoc
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
= Poepod

Poepod is a Ruby gem that provides functionality to concatenate code files from
a directory into one text file for analysis by Poe.
Poepod is a Ruby gem that streamlines the process of preparing code for analysis
by Poe. It offers two main features: concatenating multiple files into a single
text file, and wrapping gem contents including unstaged files. These features
are particularly useful for developers who want to quickly gather code for
review, analysis, or submission to AI-powered coding assistants.

== Installation

Expand All @@ -28,49 +31,102 @@ $ gem install poepod

== Usage

After installation, you can use the `poepod` command line tool to concatenate
code files:
After installation, you can use the `poepod` command line tool:

[source,shell]
----
$ poepod help
Commands:
poepod concat DIRECTORY OUTPUT_FILE # Concatenate code from a directory into one text file
poepod help [COMMAND] # Describe available commands or one specific command
poepod concat FILES [OUTPUT_FILE] # Concatenate specified files into one text file
poepod help [COMMAND] # Describe available commands or one specific command
poepod wrap GEMSPEC_PATH # Wrap a gem based on its gemspec file
----

=== Concatenating files

The `concat` command allows you to combine multiple files into a single text
file. This is particularly useful when you want to review or analyze code from
multiple files in one place, or when preparing code submissions for AI-powered
coding assistants.

[source,shell]
----
$ poepod concat path/to/files/* output.txt
----

This will concatenate all files from the specified path into `output.txt`.

==== Excluding patterns

You can exclude certain patterns using the `--exclude` option:

[source,shell]
----
$ poepod concat path/to/files/* output.txt --exclude node_modules .git build test
----

This is helpful when you want to focus on specific parts of your codebase,
excluding irrelevant or large directories.

$ poepod help concat
Usage:
poepod concat DIRECTORY OUTPUT_FILE
==== Including binary files

Options:
[--exclude=one two three] # List of patterns to exclude
# Default: "node_modules/" ".git/" "build" "test" ".gitignore" ".DS_Store" "*.jpg" "*.jpeg" "*.png" "*.svg" "*.gif" "*.exe" "*.dll" "*.so" "*.bin" "*.o" "*.a"
[--config=CONFIG] # Path to configuration file
By default, binary files are excluded to keep the output focused on readable
code. However, you can include binary files (encoded in MIME format) using the
`--include-binary` option:

Concatenate code from a directory into one text file
[source,shell]
----
$ poepod concat path/to/files/* output.txt --include-binary
----

For example:
This can be useful when you need to include binary assets or compiled files in
your analysis.

=== Wrapping a gem

The `wrap` command creates a comprehensive snapshot of your gem, including all
files specified in the gemspec and README files. This is particularly useful for
gem developers who want to review their entire gem contents or prepare it for
submission to code review tools.

[source,shell]
----
$ poepod concat my_project
# => concatenated into my_project.txt
$ poepod wrap path/to/your_gem.gemspec
----

This will concatenate all code files from the specified directory into `output.txt`.
This will create a file named `your_gem_wrapped.txt` containing all the files
specified in the gemspec, including README files.

==== Handling unstaged files

You can also exclude certain directories or files by using the `--exclude` option:
By default, unstaged files in the `lib/`, `spec/`, and `test/` directories are
not included in the wrap, but they will be listed as a warning. This default
behavior ensures that the wrapped content matches what's currently tracked in
your version control system.

However, there are cases where including unstaged files can be beneficial:

. When you're actively developing and want to include recent changes that
haven't been committed yet.

. When you're seeking feedback on work-in-progress code.

. When you want to ensure you're not missing any important files in your commit.

To include these unstaged files in the wrap:

[source,shell]
----
$ poepod concat my_project output.txt --exclude node_modules .git build test .gitignore .DS_Store .jpg .png .svg
$ poepod wrap path/to/your_gem.gemspec --include-unstaged
----

This option allows you to capture a true snapshot of your gem's current state,
including any work in progress.

== Development

After checking out the repo, run `bin/setup` to install dependencies. Then, run
`rake test` to run the tests. You can also run `bin/console` for an interactive
`rake spec` to run the tests. You can also run `bin/console` for an interactive
prompt that will allow you to experiment.

To install this gem onto your local machine, run `bundle exec rake install`. To
Expand All @@ -81,4 +137,5 @@ https://rubygems.org.

== Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/riboseinc/poepod.
Bug reports and pull requests are welcome on GitHub at https://github.com/riboseinc/poepod.
Please adhere to the link:CODE_OF_CONDUCT.md[code of conduct].
69 changes: 51 additions & 18 deletions lib/poepod/cli.rb
Original file line number Diff line number Diff line change
@@ -1,36 +1,69 @@
# frozen_string_literal: true

# lib/poepod/cli.rb
require "thor"
require_relative "processor"
require_relative "file_processor"
require_relative "gem_processor"

module Poepod
class Cli < Thor
desc "concat DIRECTORY OUTPUT_FILE", "Concatenate code from a directory into one text file"
option :exclude, type: :array, default: Poepod::Processor::EXCLUDE_DEFAULT, desc: "List of patterns to exclude"
desc "concat FILES [OUTPUT_FILE]", "Concatenate specified files into one text file"
option :exclude, type: :array, default: Poepod::FileProcessor::EXCLUDE_DEFAULT, desc: "List of patterns to exclude"
option :config, type: :string, desc: "Path to configuration file"
option :include_binary, type: :boolean, default: false, desc: "Include binary files (encoded in MIME format)"

def concat(directory, output_file = nil)
dir_path = Pathname.new(directory)

# Check if the directory exists
unless dir_path.directory?
puts "Error: Directory '#{directory}' does not exist."
def concat(*files, output_file: nil)
if files.empty?
puts "Error: No files specified."
exit(1)
end

dir_path = dir_path.expand_path unless dir_path.absolute?
output_file ||= default_output_file(files.first)
output_path = Pathname.new(output_file).expand_path

processor = Poepod::FileProcessor.new(files, output_path, options[:config], options[:include_binary])
total_files, copied_files = processor.process

puts "-> #{total_files} files detected."
puts "=> #{copied_files} files have been concatenated into #{output_path.relative_path_from(Dir.pwd)}."
end

desc "wrap GEMSPEC_PATH", "Wrap a gem based on its gemspec file"
option :include_unstaged, type: :boolean, default: false, desc: "Include unstaged files from lib, spec, and test directories"

output_file ||= "#{dir_path.basename}.txt"
output_path = dir_path.dirname.join(output_file)
processor = Poepod::Processor.new(options[:config])
total_files, copied_files = processor.write_directory_structure_to_file(directory, output_path, options[:exclude])
def wrap(gemspec_path)
processor = Poepod::GemProcessor.new(gemspec_path, nil, options[:include_unstaged])
success, result, unstaged_files = processor.process

puts "-> #{total_files} files detected in the #{dir_path.relative_path_from(Dir.pwd)} directory."
puts "=> #{copied_files} files have been concatenated into #{Pathname.new(output_path).relative_path_from(Dir.pwd)}."
if success
puts "=> The gem has been wrapped into '#{result}'."
if unstaged_files.any?
puts "\nWarning: The following files are not staged in git:"
puts unstaged_files
puts "\nThese files are #{options[:include_unstaged] ? "included" : "not included"} in the wrap."
puts "Use --include-unstaged option to include these files." unless options[:include_unstaged]
end
else
puts result
exit(1)
end
end

def self.exit_on_failure?
true
end

private

def default_output_file(first_pattern)
first_item = Dir.glob(first_pattern).first
if first_item
if File.directory?(first_item)
"#{File.basename(first_item)}.txt"
else
"#{File.basename(first_item, ".*")}_concat.txt"
end
else
"concatenated_output.txt"
end
end
end
end
77 changes: 77 additions & 0 deletions lib/poepod/file_processor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
require_relative "processor"
require "yaml"
require "tqdm"
require "pathname"
require "open3"
require "base64"
require "mime/types"

module Poepod
class FileProcessor < Processor
EXCLUDE_DEFAULT = [
/node_modules\//, /.git\//, /.gitignore$/, /.DS_Store$/,
].freeze

def initialize(files, output_file, config_file = nil, include_binary = false)
super(config_file)
@files = files
@output_file = output_file
@failed_files = []
@include_binary = include_binary
end

def process
total_files = 0
copied_files = 0

File.open(@output_file, "w", encoding: "utf-8") do |output|
@files.each do |file|
Dir.glob(file).each do |matched_file|
if File.file?(matched_file)
total_files += 1
file_path, content, error = process_file(matched_file)
if content
output.puts "--- START FILE: #{file_path} ---"
output.puts content
output.puts "--- END FILE: #{file_path} ---"
copied_files += 1
elsif error
output.puts "#{file_path}\n#{error}"
end
end
end
end
end

[total_files, copied_files]
end

private

def process_file(file_path)
if text_file?(file_path)
content = File.read(file_path, encoding: "utf-8")
[file_path, content, nil]
elsif @include_binary
content = encode_binary_file(file_path)
[file_path, content, nil]
else
[file_path, nil, "Skipped binary file"]
end
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
@failed_files << file_path
[file_path, nil, "Failed to decode the file, as it is not saved with UTF-8 encoding."]
end

def text_file?(file_path)
stdout, status = Open3.capture2("file", "-b", "--mime-type", file_path)
status.success? && stdout.strip.start_with?("text/")
end

def encode_binary_file(file_path)
mime_type = MIME::Types.type_for(file_path).first.content_type
encoded_content = Base64.strict_encode64(File.binread(file_path))
"Content-Type: #{mime_type}\nContent-Transfer-Encoding: base64\n\n#{encoded_content}"
end
end
end
79 changes: 79 additions & 0 deletions lib/poepod/gem_processor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# lib/poepod/gem_processor.rb
require_relative "processor"
require "rubygems/specification"
require "git"

module Poepod
class GemProcessor < Processor
def initialize(gemspec_path, config_file = nil, include_unstaged = false)
super(config_file)
@gemspec_path = gemspec_path
@include_unstaged = include_unstaged
end

def process
unless File.exist?(@gemspec_path)
return [false, "Error: The specified gemspec file '#{@gemspec_path}' does not exist."]
end

begin
spec = Gem::Specification.load(@gemspec_path)
rescue => e
return [false, "Error loading gemspec: #{e.message}"]
end

gem_name = spec.name
output_file = "#{gem_name}_wrapped.txt"
unstaged_files = check_unstaged_files

File.open(output_file, "w") do |file|
file.puts "# Wrapped Gem: #{gem_name}"
file.puts "## Gemspec: #{File.basename(@gemspec_path)}"

if unstaged_files.any?
file.puts "\n## Warning: Unstaged Files"
file.puts unstaged_files.join("\n")
file.puts "\nThese files are not included in the wrap unless --include-unstaged option is used."
end

file.puts "\n## Files:\n"

files_to_include = (spec.files + spec.test_files + find_readme_files).uniq
files_to_include += unstaged_files if @include_unstaged

files_to_include.uniq.each do |relative_path|
full_path = File.join(File.dirname(@gemspec_path), relative_path)
if File.file?(full_path)
file.puts "--- START FILE: #{relative_path} ---"
file.puts File.read(full_path)
file.puts "--- END FILE: #{relative_path} ---\n\n"
end
end
end

[true, output_file, unstaged_files]
end

private

def find_readme_files
Dir.glob(File.join(File.dirname(@gemspec_path), "README*"))
.map { |path| Pathname.new(path).relative_path_from(Pathname.new(File.dirname(@gemspec_path))).to_s }
end

def check_unstaged_files
gem_root = File.dirname(@gemspec_path)
git = Git.open(gem_root)

untracked_files = git.status.untracked.keys
modified_files = git.status.changed.keys

(untracked_files + modified_files).select do |file|
file.start_with?("lib/", "spec/", "test/")
end
rescue Git::GitExecuteError => e
warn "Git error: #{e.message}. Assuming no unstaged files."
[]
end
end
end
Loading

0 comments on commit a65a841

Please sign in to comment.