diff --git a/bin/nit-ruby b/bin/nit-ruby new file mode 100755 index 0000000..7d74f32 --- /dev/null +++ b/bin/nit-ruby @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec ruby "${SCRIPT_DIR}/../ruby/nit.rb" "$@" diff --git a/ruby/Gemfile b/ruby/Gemfile new file mode 100644 index 0000000..2d1995f --- /dev/null +++ b/ruby/Gemfile @@ -0,0 +1,7 @@ +source "https://rubygems.org" + +gem "parallel", "~> 1.24" + +group :development, :test do + gem "rspec", "~> 3.12" +end diff --git a/ruby/Gemfile.lock b/ruby/Gemfile.lock new file mode 100644 index 0000000..4e7cb13 --- /dev/null +++ b/ruby/Gemfile.lock @@ -0,0 +1,38 @@ +GEM + remote: https://rubygems.org/ + specs: + diff-lcs (1.6.2) + parallel (1.27.0) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.7) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.6) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + parallel (~> 1.24) + rspec (~> 3.12) + +CHECKSUMS + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.7) sha256=0979034e64b1d7a838aaaddf12bf065ea4dc40ef3d4c39f01f93ae2c66c62b1c + rspec-support (3.13.6) sha256=2e8de3702427eab064c9352fe74488cc12a1bfae887ad8b91cba480ec9f8afb2 + +BUNDLED WITH + 2.7.2 diff --git a/ruby/nit.rb b/ruby/nit.rb new file mode 100644 index 0000000..033097d --- /dev/null +++ b/ruby/nit.rb @@ -0,0 +1,235 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "optparse" +require "open3" +require "parallel" +require "shellwords" + +VERSION = "0.1.0" +MAX_REPO_NAME_WIDTH = 24 + +# === Passthrough Check === +# Per SPEC.md 1.1: Must check if inside git repo before any other operation + +def inside_git_repo? + system("git", "rev-parse", "--git-dir", out: File::NULL, err: File::NULL) +end + +# === GitCommand Struct === +# Critical: to_s (dry-run) and run both use to_cmd - same code path per SPEC.md 6.1.1 + +GitCommand = Struct.new(:repo, :args, :url_scheme) do + def to_cmd + cmd = ["git"] + case url_scheme + when :ssh + cmd += ["-c", "url.git@github.com:.insteadOf=https://github.com/"] + when :https + cmd += ["-c", "url.https://github.com/.insteadOf=git@github.com:"] + end + cmd += ["-C", repo] + args + end + + def to_s + to_cmd.shelljoin + end + + def run + Open3.capture3({ "GIT_TERMINAL_PROMPT" => "0" }, *to_cmd) + end +end + +# === Output Formatters === + +FORMATTERS = { + status: lambda { |stdout, stderr, success| + return "ERROR: #{stderr.lines.first&.strip || 'failed'}" unless success + + modified = 0 + untracked = 0 + + stdout.each_line do |line| + next if line.length < 2 + + if line.start_with?("??") + untracked += 1 + elsif line =~ /^[ MADRCU][MD]|^[MADRCU][ MD]/ + modified += 1 + end + end + + return "clean" if modified.zero? && untracked.zero? + + parts = [] + parts << "#{modified} modified" if modified > 0 + parts << "#{untracked} untracked" if untracked > 0 + parts.join(", ") + }, + + pull: lambda { |stdout, stderr, success| + return "ERROR: #{stderr.lines.first&.strip || 'failed'}" unless success + return "Already up to date" if stdout.include?("Already up to date") + + # Match both "1 file changed" and "N files changed" + stdout.lines.find { |l| l.include?("file changed") || l.include?("files changed") }&.strip || "completed" + }, + + fetch: lambda { |stdout, stderr, success| + return "ERROR: #{stderr.lines.first&.strip || 'failed'}" unless success + return "no new commits" if stdout.strip.empty? && stderr.strip.empty? + + "fetched" + }, + + passthrough: lambda { |stdout, stderr, success| + return "ERROR: #{stderr.lines.first&.strip || 'failed'}" unless success + + stdout.lines.first&.strip || stderr.lines.first&.strip || "ok" + } +}.freeze + +# === Helper Functions === + +def find_repos + Dir.glob("*/.git").map { |p| File.dirname(p) }.sort +end + +def format_repo_name(name) + display = if name.length > MAX_REPO_NAME_WIDTH + "#{name[0, MAX_REPO_NAME_WIDTH - 4]}-..." + else + name + end + "[#{display.ljust(MAX_REPO_NAME_WIDTH)}]" +end + +# === Parallel Execution === + +def run_parallel(repos, command:, args:, dry_run:, workers:, url_scheme:, formatter:) + # Build commands for all repos + commands = repos.map do |repo| + full_args = case command + when "status" + ["status", "--porcelain"] + args + else + [command] + args + end + GitCommand.new(repo, full_args, url_scheme) + end + + # Dry-run: print commands and exit + if dry_run + puts "[nit v#{VERSION}] Running in **dry-run mode**, no git commands will be executed. Planned git commands below." + commands.each { |cmd| puts cmd.to_s } + return + end + + # Parallel execution with processes + worker_count = workers.zero? ? repos.size : [workers, repos.size].min + + results = Parallel.map(commands, in_processes: worker_count) do |cmd| + stdout, stderr, status = cmd.run + { repo: cmd.repo, stdout: stdout, stderr: stderr, success: status.success? } + end + + # Print results in order (Parallel.map preserves order) + results.each do |r| + name = format_repo_name(File.basename(r[:repo])) + output = formatter.call(r[:stdout], r[:stderr], r[:success]) + puts "#{name} #{output}" + end +end + +# === Main Entry Point === +# Only execute when run directly (not when required for testing) + +if __FILE__ == $0 + # Passthrough check FIRST per SPEC.md 1.1 + if inside_git_repo? + exec("git", *ARGV) # Never returns - replaces process with git + end + + # CLI Parsing + options = { dry_run: false, workers: 8, url_scheme: nil } + + parser = OptionParser.new do |opts| + opts.banner = "Usage: nit [OPTIONS] [ARGS...]" + opts.separator "" + opts.separator "Commands:" + opts.separator " status Show status of all repositories" + opts.separator " pull Pull all repositories" + opts.separator " fetch Fetch all repositories" + opts.separator " Pass through to git" + opts.separator "" + opts.separator "Options:" + + opts.on("--dry-run", "Print exact commands without executing") do + options[:dry_run] = true + end + + opts.on("-n", "--max-connections NUM", Integer, "Max concurrent git processes (default: 8, 0 = unlimited)") do |n| + options[:workers] = n + end + + opts.on("--ssh", "Force SSH URLs for all remotes") do + options[:url_scheme] = :ssh + end + + opts.on("--https", "Force HTTPS URLs for all remotes") do + options[:url_scheme] = :https + end + + opts.on("-V", "--version", "Print version") do + puts "nit #{VERSION}" + exit + end + + opts.on("-h", "--help", "Print help") do + puts opts + exit + end + end + + begin + args = parser.order(ARGV) # Parse options, stop at first non-option + rescue OptionParser::InvalidOption => e + warn "nit: #{e.message}" + exit 1 + end + + command = args.shift + git_args = args + + # Main Execution + repos = find_repos + + if repos.empty? + puts "No git repositories found in current directory" + exit 0 + end + + # Handle no command - default to showing help + if command.nil? + warn "nit: no command specified" + warn parser + exit 1 + end + + formatter = case command + when "status" then FORMATTERS[:status] + when "pull" then FORMATTERS[:pull] + when "fetch" then FORMATTERS[:fetch] + else FORMATTERS[:passthrough] + end + + run_parallel( + repos, + command: command, + args: git_args, + dry_run: options[:dry_run], + workers: options[:workers], + url_scheme: options[:url_scheme], + formatter: formatter + ) +end diff --git a/ruby/spec/nit_spec.rb b/ruby/spec/nit_spec.rb new file mode 100644 index 0000000..0bb2553 --- /dev/null +++ b/ruby/spec/nit_spec.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +RSpec.describe "nit" do + describe "inside_git_repo?" do + it "returns true when inside a git repo" do + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + system("git", "init", "--quiet", out: File::NULL, err: File::NULL) + expect(inside_git_repo?).to be true + end + end + end + + it "returns false when outside a git repo" do + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + expect(inside_git_repo?).to be false + end + end + end + end + + describe "find_repos" do + it "finds repos at depth 1" do + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + %w[repo-a repo-b not-a-repo].each { |d| Dir.mkdir(d) } + %w[repo-a repo-b].each { |d| Dir.mkdir("#{d}/.git") } + + repos = find_repos + expect(repos).to eq(%w[repo-a repo-b]) + end + end + end + + it "returns repos sorted alphabetically" do + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + %w[zebra alpha middle].each do |name| + Dir.mkdir(name) + Dir.mkdir("#{name}/.git") + end + + repos = find_repos + expect(repos).to eq(%w[alpha middle zebra]) + end + end + end + + it "returns empty array when no repos found" do + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + Dir.mkdir("not-a-repo") + expect(find_repos).to eq([]) + end + end + end + end + + describe "format_repo_name" do + it "pads short names to fixed width" do + result = format_repo_name("foo") + expect(result).to eq("[foo ]") + expect(result.length).to eq(26) # brackets + 24 chars + end + + it "handles exact width names" do + name = "a" * MAX_REPO_NAME_WIDTH + result = format_repo_name(name) + expect(result).to eq("[#{name}]") + end + + it "truncates long names with ellipsis" do + name = "a" * 30 + result = format_repo_name(name) + expect(result.length).to eq(26) + expect(result).to end_with("-...]") + end + end + + describe "GitCommand" do + describe "#to_cmd" do + it "builds basic command array" do + cmd = GitCommand.new("my-repo", ["status", "--porcelain"], nil) + expect(cmd.to_cmd).to eq(["git", "-C", "my-repo", "status", "--porcelain"]) + end + + it "includes SSH config when url_scheme is :ssh" do + cmd = GitCommand.new("my-repo", ["pull"], :ssh) + argv = cmd.to_cmd + expect(argv).to include("-c", "url.git@github.com:.insteadOf=https://github.com/") + expect(argv.index("-c")).to be < argv.index("-C") + end + + it "includes HTTPS config when url_scheme is :https" do + cmd = GitCommand.new("my-repo", ["fetch"], :https) + argv = cmd.to_cmd + expect(argv).to include("-c", "url.https://github.com/.insteadOf=git@github.com:") + end + end + + describe "#to_s" do + it "returns shell-escaped command string" do + cmd = GitCommand.new("my-repo", ["status"], nil) + expect(cmd.to_s).to eq("git -C my-repo status") + end + + it "escapes paths with spaces" do + cmd = GitCommand.new("my repo", ["pull"], nil) + expect(cmd.to_s).to include("my\\ repo") + end + + it "uses same argv as to_cmd (dry-run compliance)" do + cmd = GitCommand.new("repo", ["fetch", "--all"], :ssh) + # to_s should be the shell-joined version of to_cmd + expect(cmd.to_s).to eq(cmd.to_cmd.shelljoin) + end + end + end + + describe "FORMATTERS" do + describe ":status" do + let(:formatter) { FORMATTERS[:status] } + + it "returns 'clean' for empty porcelain output" do + expect(formatter.call("", "", true)).to eq("clean") + end + + it "counts modified files" do + output = " M file1.rb\n M file2.rb\n" + expect(formatter.call(output, "", true)).to eq("2 modified") + end + + it "counts untracked files" do + output = "?? new_file.rb\n?? another.rb\n" + expect(formatter.call(output, "", true)).to eq("2 untracked") + end + + it "counts staged files" do + output = "M staged.rb\nA added.rb\n" + expect(formatter.call(output, "", true)).to eq("2 modified") + end + + it "combines multiple statuses" do + output = " M modified.rb\n?? untracked.rb\n" + expect(formatter.call(output, "", true)).to eq("1 modified, 1 untracked") + end + + it "returns error message on failure" do + expect(formatter.call("", "fatal: not a git repository\n", false)) + .to eq("ERROR: fatal: not a git repository") + end + end + + describe ":pull" do + let(:formatter) { FORMATTERS[:pull] } + + it "returns 'Already up to date' when no changes" do + expect(formatter.call("Already up to date.\n", "", true)) + .to eq("Already up to date") + end + + it "extracts files changed summary" do + output = <<~OUTPUT + Updating abc123..def456 + Fast-forward + README.md | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + OUTPUT + expect(formatter.call(output, "", true)) + .to eq("1 file changed, 1 insertion(+), 1 deletion(-)") + end + + it "returns 'completed' when no summary found" do + expect(formatter.call("Some other output\n", "", true)).to eq("completed") + end + + it "returns error message on failure" do + expect(formatter.call("", "error: failed\n", false)) + .to eq("ERROR: error: failed") + end + end + + describe ":fetch" do + let(:formatter) { FORMATTERS[:fetch] } + + it "returns 'no new commits' when nothing fetched" do + expect(formatter.call("", "", true)).to eq("no new commits") + end + + it "returns 'fetched' when something fetched" do + expect(formatter.call("", "From github.com:foo/bar\n", true)).to eq("fetched") + end + + it "returns error message on failure" do + expect(formatter.call("", "fatal: error\n", false)) + .to eq("ERROR: fatal: error") + end + end + + describe ":passthrough" do + let(:formatter) { FORMATTERS[:passthrough] } + + it "returns first line of stdout" do + expect(formatter.call("line 1\nline 2\n", "", true)).to eq("line 1") + end + + it "falls back to stderr if stdout empty" do + expect(formatter.call("", "warning: something\n", true)).to eq("warning: something") + end + + it "returns 'ok' when both empty" do + expect(formatter.call("", "", true)).to eq("ok") + end + + it "returns error message on failure" do + expect(formatter.call("", "error: something\n", false)) + .to eq("ERROR: error: something") + end + end + end +end diff --git a/ruby/spec/spec_helper.rb b/ruby/spec/spec_helper.rb new file mode 100644 index 0000000..53ae0cb --- /dev/null +++ b/ruby/spec/spec_helper.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "tmpdir" +require "fileutils" + +# Load nit.rb - the executable code is wrapped in `if __FILE__ == $0` +# so only function/class definitions are loaded when requiring +require_relative "../nit" + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.filter_run_when_matching :focus + config.disable_monkey_patching! + config.warnings = true + + config.default_formatter = "doc" if config.files_to_run.one? + + config.order = :random + Kernel.srand config.seed +end diff --git a/script/test b/script/test index af0cad7..87b1bae 100755 --- a/script/test +++ b/script/test @@ -13,13 +13,14 @@ Usage: test [options] Options: -h, --help Show this help message - -l, --language LANG Run tests for specific language only (rust, zig) + -l, --language LANG Run tests for specific language only (rust, zig, ruby) -v, --verbose Show verbose test output Examples: test Run all implementation tests test -l rust Run only Rust tests test --language zig Run only Zig tests + test -l ruby Run only Ruby tests test -v Run all tests with verbose output EOF } @@ -78,6 +79,33 @@ run_zig_tests() { return $status } +run_ruby_tests() { + local ruby_dir="${SCRIPT_DIR}/../ruby" + if [[ ! -d "$ruby_dir" ]]; then + echo "Ruby implementation not found at $ruby_dir" + return 1 + fi + + echo "=== Running Ruby tests ===" + echo + + local rspec_opts=() + if $VERBOSE; then + rspec_opts+=("--format" "documentation") + fi + + (cd "$ruby_dir" && bundle exec rspec "${rspec_opts[@]}") + local status=$? + + echo + if [[ $status -eq 0 ]]; then + echo "✓ Ruby tests passed" + else + echo "✗ Ruby tests failed" + fi + return $status +} + main() { while [[ $# -gt 0 ]]; do case "$1" in @@ -109,10 +137,10 @@ main() { # Validate language if specified if [[ -n "$LANGUAGE" ]]; then case "$LANGUAGE" in - rust|zig) ;; + rust|zig|ruby) ;; *) echo "Unknown language: $LANGUAGE" - echo "Supported: rust, zig" + echo "Supported: rust, zig, ruby" exit 1 ;; esac @@ -131,6 +159,12 @@ main() { if [[ -z "$LANGUAGE" || "$LANGUAGE" == "zig" ]]; then run_zig_tests || failed=1 run_count=$((run_count + 1)) + echo + fi + + if [[ -z "$LANGUAGE" || "$LANGUAGE" == "ruby" ]]; then + run_ruby_tests || failed=1 + run_count=$((run_count + 1)) fi echo