loading
Generated 2025-10-08T23:58:40+00:00

All Files ( 100.0% covered at 13.7 hits/line )

28 files in total.
431 relevant lines, 431 lines covered and 0 lines missed. ( 100.0% )
62 total branches, 62 branches covered and 0 branches missed. ( 100.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
lib/gitt.rb 100.00 % 22 12 12 0 1.42 100.00 % 0 0 0
lib/gitt/commands/branch.rb 100.00 % 31 14 14 0 4.21 100.00 % 2 2 0
lib/gitt/commands/config.rb 100.00 % 33 16 16 0 4.63 100.00 % 2 2 0
lib/gitt/commands/log.rb 100.00 % 86 36 36 0 20.56 100.00 % 2 2 0
lib/gitt/commands/tag.rb 100.00 % 118 55 55 0 6.55 100.00 % 6 6 0
lib/gitt/directable.rb 100.00 % 16 7 7 0 1.00 100.00 % 0 0 0
lib/gitt/models/commit.rb 100.00 % 44 10 10 0 13.80 100.00 % 0 0 0
lib/gitt/models/person.rb 100.00 % 25 13 13 0 3.08 100.00 % 4 4 0
lib/gitt/models/tag.rb 100.00 % 30 7 7 0 6.43 100.00 % 0 0 0
lib/gitt/models/trailer.rb 100.00 % 18 8 8 0 14.25 100.00 % 0 0 0
lib/gitt/parsers/attributer.rb 100.00 % 30 14 14 0 74.29 100.00 % 2 2 0
lib/gitt/parsers/commit.rb 100.00 % 76 37 37 0 21.54 100.00 % 2 2 0
lib/gitt/parsers/person.rb 100.00 % 29 15 15 0 3.13 100.00 % 4 4 0
lib/gitt/parsers/tag.rb 100.00 % 54 24 24 0 11.54 100.00 % 2 2 0
lib/gitt/parsers/trailer.rb 100.00 % 36 15 15 0 7.73 100.00 % 4 4 0
lib/gitt/repository.rb 100.00 % 77 35 35 0 5.74 100.00 % 0 0 0
lib/gitt/sanitizers/container.rb 100.00 % 17 3 3 0 1.00 100.00 % 0 0 0
lib/gitt/sanitizers/date.rb 100.00 % 9 4 4 0 7.00 100.00 % 2 2 0
lib/gitt/sanitizers/email.rb 100.00 % 9 4 4 0 7.00 100.00 % 2 2 0
lib/gitt/sanitizers/lines.rb 100.00 % 9 4 4 0 18.00 100.00 % 2 2 0
lib/gitt/sanitizers/paragraphs.rb 100.00 % 53 21 21 0 17.67 100.00 % 3 3 0
lib/gitt/sanitizers/scissors.rb 100.00 % 9 4 4 0 9.75 100.00 % 2 2 0
lib/gitt/sanitizers/signature.rb 100.00 % 19 13 13 0 6.69 100.00 % 9 9 0
lib/gitt/sanitizers/statistics.rb 100.00 % 42 20 20 0 12.45 100.00 % 6 6 0
lib/gitt/sanitizers/trailers.rb 100.00 % 22 10 10 0 7.80 100.00 % 0 0 0
lib/gitt/sanitizers/version.rb 100.00 % 9 4 4 0 4.25 100.00 % 2 2 0
lib/gitt/shell.rb 100.00 % 27 13 13 0 57.31 100.00 % 2 2 0
lib/gitt/trailable.rb 100.00 % 28 13 13 0 8.92 100.00 % 2 2 0

lib/gitt.rb

100.0% lines covered

100.0% branches covered

12 relevant lines. 12 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "zeitwerk"
  3. 1 Zeitwerk::Loader.new.then do |loader|
  4. 1 loader.inflector.inflect "container" => "CONTAINER"
  5. 1 loader.ignore "#{__dir__}/gitt/rspec/shared_contexts"
  6. 1 loader.tag = File.basename __FILE__, ".rb"
  7. 1 loader.push_dir __dir__
  8. 1 loader.setup
  9. end
  10. # Main namespace.
  11. 1 module Gitt
  12. 1 SHELL = Shell.new.freeze
  13. 1 def self.loader registry = Zeitwerk::Registry
  14. 6 @loader ||= registry.loaders.each.find { |loader| loader.tag == File.basename(__FILE__, ".rb") }
  15. end
  16. 1 def self.new(...) = Repository.new(...)
  17. end

lib/gitt/commands/branch.rb

100.0% lines covered

100.0% branches covered

14 relevant lines. 14 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "dry/monads"
  3. 1 module Gitt
  4. 1 module Commands
  5. # A Git branch command wrapper.
  6. 1 class Branch
  7. 1 include Dry::Monads[:result]
  8. 1 def initialize shell: SHELL
  9. 37 @shell = shell
  10. end
  11. 1 def default(fallback = "main", *, **)
  12. 6 shell.call("config", "init.defaultBranch", *, **)
  13. .fmap(&:chomp)
  14. 5 then: 3 else: 2 .fmap { |name| name.empty? ? fallback : name }
  15. .or(Success(fallback))
  16. end
  17. 1 def call(*, **) = shell.call("branch", *, **)
  18. 1 def name(*, **) = shell.call("rev-parse", "--abbrev-ref", "HEAD", *, **).fmap(&:chomp)
  19. 1 private
  20. 1 attr_reader :shell
  21. end
  22. end
  23. end

lib/gitt/commands/config.rb

100.0% lines covered

100.0% branches covered

16 relevant lines. 16 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "core"
  3. 1 require "dry/monads"
  4. 1 module Gitt
  5. 1 module Commands
  6. # A Git config command wrapper.
  7. 1 class Config
  8. 1 include Dry::Monads[:result]
  9. 1 def initialize shell: SHELL
  10. 41 @shell = shell
  11. end
  12. 1 def call(*, **) = shell.call("config", *, **)
  13. 1 def get(key, fallback = Core::EMPTY_STRING, *, **)
  14. 11 shell.call("config", "--get", key, *, **)
  15. .fmap(&:chomp)
  16. 4 then: 1 else: 3 .or { |error| block_given? ? yield(error) : Success(fallback) }
  17. end
  18. 1 def origin? = !get("remote.origin.url").value_or(Core::EMPTY_STRING).empty?
  19. 6 def set(key, value, *, **) = shell.call("config", "--add", key, value, *, **).fmap { value }
  20. 1 private
  21. 1 attr_reader :shell
  22. end
  23. end
  24. end

lib/gitt/commands/log.rb

100.0% lines covered

100.0% branches covered

36 relevant lines. 36 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "dry/monads"
  3. 1 module Gitt
  4. 1 module Commands
  5. # A Git log command wrapper.
  6. 1 class Log
  7. 1 include Dry::Monads[:result]
  8. 1 KEY_MAP = {
  9. author_email: "%ae",
  10. author_name: "%an",
  11. authored_at: "%at",
  12. authored_relative_at: "%ar",
  13. body: "%b",
  14. committed_at: "%ct",
  15. committed_relative_at: "%cr",
  16. committer_email: "%ce",
  17. committer_name: "%cn",
  18. encoding: "%e",
  19. notes: "%N",
  20. raw: "%B",
  21. sha: "%H",
  22. signature: "%G?",
  23. fingerprint: "%GK",
  24. fingerprint_key: "%GF",
  25. subject: "%s",
  26. trailers: "%(trailers)"
  27. }.freeze
  28. 1 def initialize shell: SHELL, key_map: KEY_MAP, parser: Parsers::Commit.new
  29. 48 @shell = shell
  30. 48 @key_map = key_map
  31. 48 @parser = parser
  32. end
  33. 1 def call(*, **) = shell.call("log", *, **)
  34. 1 def index(*arguments, **)
  35. 18 arguments.prepend("--shortstat", pretty_format)
  36. 18 .then { |pretty_format| call(*pretty_format, **) }
  37. 17 .fmap { |content| String(content).scrub("?") }
  38. 17 .fmap { |entries| build_records entries }
  39. end
  40. 1 def uncommitted path
  41. 5 else: 4 then: 1 return Failure %(Invalid commit message path: "#{path}".) unless path.exist?
  42. 4 shell.call("mktree")
  43. 4 .bind { |raw_sha| shell.call "commit-tree", "-F", path.to_s, raw_sha.chomp }
  44. 4 .bind { |sha| index "-1", sha.chomp }
  45. .fmap(&:first)
  46. end
  47. 1 private
  48. 1 attr_reader :shell, :key_map, :parser
  49. 1 def pretty_format
  50. 342 key_map.reduce(+"") { |format, (key, value)| format << "<#{key}>#{value}</#{key}>%n" }
  51. 18 .then { |format| %(--pretty=format:"#{format}") }
  52. end
  53. 1 def build_records entries
  54. 17 wrap_statistics entries
  55. 17 add_empty_statistics entries
  56. 37 entries.split("<break/>").map { |entry| parser.call entry }
  57. end
  58. # :reek:UtilityFunction
  59. 1 def wrap_statistics entries
  60. 17 entries.gsub!(/\n"\n\s\d+\sfile.+\d+\s(insertion|deletion).+\n/) do |match|
  61. 14 match.delete_prefix!("\n\"\n").strip!
  62. 14 "\n<statistics>#{match}</statistics>\n<break/>"
  63. end
  64. end
  65. # :reek:UtilityFunction
  66. 1 def add_empty_statistics entries
  67. 17 entries.gsub! %(</trailers>\n"\n"<author_email>),
  68. "</trailers>\n<statistics></statistics><break/>\n<author_email>"
  69. end
  70. end
  71. end
  72. end

lib/gitt/commands/tag.rb

100.0% lines covered

100.0% branches covered

55 relevant lines. 55 lines covered and 0 lines missed.
6 total branches, 6 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "core"
  3. 1 require "dry/monads"
  4. 1 require "tempfile"
  5. 1 module Gitt
  6. 1 module Commands
  7. # A Git tag command wrapper.
  8. 1 class Tag
  9. 1 include Dry::Monads[:result]
  10. 1 KEY_MAP = {
  11. author_email: "%(*authoremail)",
  12. author_name: "%(*authorname)",
  13. authored_at: "%(*authordate:raw)",
  14. authored_relative_at: "%(*authordate:relative)",
  15. body: "%(body)",
  16. committed_at: "%(*committerdate:raw)",
  17. committed_relative_at: "%(*committerdate:relative)",
  18. committer_email: "%(*committeremail)",
  19. committer_name: "%(*committername)",
  20. sha: "%(objectname)",
  21. signature: "%(contents:signature)",
  22. subject: "%(subject)",
  23. trailers: "%(trailers)",
  24. version: "%(refname)"
  25. }.freeze
  26. 1 def initialize shell: SHELL, key_map: KEY_MAP, parser: Parsers::Tag.new
  27. 55 @shell = shell
  28. 55 @key_map = key_map
  29. 55 @parser = parser
  30. end
  31. 1 def call(*, **) = shell.call("tag", *, **)
  32. 1 def create version, body = Core::EMPTY_STRING, *flags
  33. 7 else: 6 then: 1 return Failure "Unable to create Git tag without version." unless version
  34. 6 then: 1 else: 5 return Failure "Tag exists: #{version}." if exist? version
  35. 5 Tempfile.open "gitt" do |file|
  36. 5 file.write body
  37. 5 write version, file.tap(&:rewind), *flags
  38. end
  39. end
  40. 1 def delete_local(version, *, **)
  41. 7 call("--delete", version, *, **).fmap { |text| text[/\d+\.\d+\.\d+/] }
  42. .alt_map do |error|
  43. 1 error.delete_prefix("error: tag ").chomp
  44. end
  45. end
  46. 1 def delete_remote(version, *, **)
  47. 3 shell.call("push", "--delete", "origin", version, *, **)
  48. 2 .fmap { version }
  49. 1 .alt_map { |error| error.gsub("error: ", "").chomp }
  50. end
  51. 1 def exist?(version) = local?(version) || remote?(version)
  52. 1 def index(*arguments, **)
  53. 2 arguments.prepend(pretty_format, "--list")
  54. 2 .then { |flags| call(*flags, **) }
  55. 2 .fmap { |content| String(content).scrub("?").split %("\n") }
  56. 2 .fmap { |entries| build_records entries }
  57. end
  58. 1 def last(*, **)
  59. 5 shell.call("describe", "--abbrev=0", "--tags", *, **)
  60. .fmap(&:strip)
  61. .or do |error|
  62. 2 then: 1 if error.match?(/no names found/i)
  63. 1 Failure "No tags found."
  64. else: 1 else
  65. 1 Failure error.delete_prefix("fatal: ").chomp
  66. end
  67. end
  68. end
  69. 1 def local? version
  70. 14 call("--list", version).value_or(Core::EMPTY_STRING).match?(/\A#{version}\Z/)
  71. end
  72. 1 def push(*, **) = shell.call("push", "--tags", *, **)
  73. 1 def remote? version
  74. 11 shell.call("ls-remote", "--tags", "origin", version)
  75. .value_or(Core::EMPTY_STRING)
  76. .match?(%r(.+tags/#{version}\Z))
  77. end
  78. 1 def show(version, *, **)
  79. 4 call(pretty_format, "--list", version, *, **).fmap { |content| parser.call content }
  80. end
  81. 1 def tagged? = !call.value_or(Core::EMPTY_STRING).empty?
  82. 1 private
  83. 1 attr_reader :shell, :key_map, :parser
  84. 1 def pretty_format
  85. 60 key_map.reduce(+"") { |format, (key, value)| format << "<#{key}>#{value}</#{key}>%n" }
  86. 4 .then { |format| %(--format="#{format}") }
  87. end
  88. 3 def build_records(entries) = entries.map { |entry| parser.call entry }
  89. 1 def write version, file, *flags
  90. 5 arguments = ["--annotate", version, "--cleanup", "verbatim", *flags, "--file", file.path]
  91. 9 call(*arguments).fmap { version }
  92. 1 .or { Failure "Unable to create tag: #{version}." }
  93. end
  94. end
  95. end
  96. end

lib/gitt/directable.rb

100.0% lines covered

100.0% branches covered

7 relevant lines. 7 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Gitt
  3. # Provides shared behavior for objects that can act like a commit.
  4. 1 module Directable
  5. 1 def directive? = amend? || fixup? || squash?
  6. 1 def amend? = subject.match?(/\Aamend!\s/)
  7. 1 def fixup? = subject.match?(/\Afixup!\s/)
  8. 1 def squash? = subject.match?(/\Asquash!\s/)
  9. 1 def prefix = subject[/\A[\w!]+/]
  10. end
  11. end

lib/gitt/models/commit.rb

100.0% lines covered

100.0% branches covered

10 relevant lines. 10 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "dry/monads"
  3. 1 module Gitt
  4. 1 module Models
  5. # Represents commit details.
  6. 1 Commit = Struct.new(
  7. :author_email,
  8. :author_name,
  9. :authored_at,
  10. :authored_relative_at,
  11. :body,
  12. :body_lines,
  13. :body_paragraphs,
  14. :committed_at,
  15. :committed_relative_at,
  16. :committer_email,
  17. :committer_name,
  18. :deletions,
  19. :encoding,
  20. :files_changed,
  21. :fingerprint,
  22. :fingerprint_key,
  23. :insertions,
  24. :lines,
  25. :notes,
  26. :raw,
  27. :sha,
  28. :signature,
  29. :subject,
  30. :trailers
  31. ) do
  32. 1 include Directable
  33. 1 include Trailable
  34. 1 include Dry::Monads[:result]
  35. 1 def initialize(**)
  36. 65 super
  37. 65 freeze
  38. end
  39. end
  40. end
  41. end

lib/gitt/models/person.rb

100.0% lines covered

100.0% branches covered

13 relevant lines. 13 lines covered and 0 lines missed.
4 total branches, 4 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "core"
  3. 1 module Gitt
  4. 1 module Models
  5. # Represents a person within a repository.
  6. 1 Person = Data.define :name, :delimiter, :email do
  7. 1 def self.for(string, parser: Parsers::Person.new) = parser.call string
  8. 1 def initialize name: nil, delimiter: " ", email: nil
  9. 21 super
  10. end
  11. 1 def to_s
  12. 6 in: 2 case self
  13. 2 in: 1 in String, String, String then "#{name}#{delimiter}<#{email}>"
  14. 1 in: 1 in String, String, nil then name
  15. 1 else: 2 in nil, String, String then "<#{email}>"
  16. 2 else Core::EMPTY_STRING
  17. end
  18. end
  19. end
  20. end
  21. end

lib/gitt/models/tag.rb

100.0% lines covered

100.0% branches covered

7 relevant lines. 7 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Gitt
  3. 1 module Models
  4. # Represents tag details.
  5. 1 Tag = Struct.new(
  6. :author_email,
  7. :author_name,
  8. :authored_at,
  9. :authored_relative_at,
  10. :body,
  11. :committed_at,
  12. :committed_relative_at,
  13. :committer_email,
  14. :committer_name,
  15. :sha,
  16. :signature,
  17. :subject,
  18. :trailers,
  19. :version
  20. ) do
  21. 1 include Trailable
  22. 1 def initialize(**)
  23. 20 super
  24. 20 freeze
  25. end
  26. end
  27. end
  28. end

lib/gitt/models/trailer.rb

100.0% lines covered

100.0% branches covered

8 relevant lines. 8 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Gitt
  3. 1 module Models
  4. # Represents commit trailer details.
  5. 1 Trailer = Data.define :key, :delimiter, :space, :value do
  6. 1 def self.for(string, parser: Parsers::Trailer.new) = parser.call string
  7. 1 def initialize key:, value:, delimiter: ":", space: " "
  8. 107 super
  9. end
  10. 1 def empty? = String(key).empty? || String(value).empty?
  11. 1 def to_s = to_h.values.join
  12. end
  13. end
  14. end

lib/gitt/parsers/attributer.rb

100.0% lines covered

100.0% branches covered

14 relevant lines. 14 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "core"
  3. 1 module Gitt
  4. 1 module Parsers
  5. # Extracts attributes from XML formatted content.
  6. 1 class Attributer
  7. 1 def initialize keys = Core::EMPTY_ARRAY
  8. 130 @keys = keys
  9. end
  10. 1 def call content
  11. 51 build String(content)
  12. rescue ArgumentError => error
  13. 2 then: 1 else: 1 error.message.include?("invalid byte") ? build(content.scrub("?")) : raise
  14. end
  15. 1 private
  16. 1 attr_reader :keys
  17. 1 def build content
  18. 51 keys.each.with_object({}) do |key, attributes|
  19. 797 attributes[key] = content[%r(<#{key}>(?<value>.*?)</#{key}>)m, :value]
  20. end
  21. end
  22. end
  23. end
  24. end

lib/gitt/parsers/commit.rb

100.0% lines covered

100.0% branches covered

37 relevant lines. 37 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "refinements/hash"
  3. 1 module Gitt
  4. 1 module Parsers
  5. # Parses raw commit to produce a commit record.
  6. 1 class Commit
  7. 1 using Refinements::Hash
  8. 1 def initialize attributer: Attributer.new(Commands::Log::KEY_MAP.keys.append(:statistics)),
  9. sanitizers: Sanitizers::CONTAINER,
  10. model: Models::Commit
  11. 61 @attributer = attributer
  12. 61 @sanitizers = sanitizers
  13. 61 @model = model
  14. end
  15. 1 def call content
  16. 33 attributer.call(content)
  17. 33 .then { |attributes| mutate attributes }
  18. 33 .then { |attributes| model[**attributes] }
  19. end
  20. 1 private
  21. 1 attr_reader :attributer, :sanitizers, :model
  22. # :reek:TooManyStatements
  23. 1 def mutate attributes
  24. 33 body, trailers = attributes.values_at :body, :trailers
  25. 33 body = scissors_sanitizer.call body
  26. 33 attributes.transform_with! signature: signature_sanitizer, trailers: trailers_sanitizer
  27. 33 attributes[:body] =
  28. 33 then: 25 else: 8 (trailers ? body.sub(/\n??#{Regexp.escape trailers}\n??/, "") : body).chomp
  29. 165 private_methods.grep(/\Aprocess_/).sort.each { |method| __send__ method, attributes }
  30. 33 attributes
  31. end
  32. # :reek:FeatureEnvy
  33. 1 def process_body_lines attributes
  34. 33 attributes[:body_lines] = lines_sanitizer.call attributes[:body]
  35. end
  36. # :reek:FeatureEnvy
  37. 1 def process_body_paragraphs attributes
  38. 33 attributes[:body_paragraphs] = paragraphs_sanitizer.call attributes[:body]
  39. end
  40. # :reek:FeatureEnvy
  41. 1 def process_lines attributes
  42. 33 attributes[:lines] = lines_sanitizer.call attributes[:raw]
  43. end
  44. # :reek:FeatureEnvy
  45. 1 def process_statistics attributes
  46. 33 attributes.merge! statistics_sanitizer.call(attributes.delete(:statistics))
  47. end
  48. 1 def lines_sanitizer = sanitizers.fetch :lines
  49. 1 def paragraphs_sanitizer = sanitizers.fetch :paragraphs
  50. 1 def scissors_sanitizer = sanitizers.fetch :scissors
  51. 1 def statistics_sanitizer = sanitizers.fetch :statistics
  52. 1 def signature_sanitizer = sanitizers.fetch :signature
  53. 1 def trailers_sanitizer = sanitizers.fetch :trailers
  54. end
  55. end
  56. end

lib/gitt/parsers/person.rb

100.0% lines covered

100.0% branches covered

15 relevant lines. 15 lines covered and 0 lines missed.
4 total branches, 4 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Gitt
  3. 1 module Parsers
  4. # Parses trailer to produce a person.
  5. 1 class Person
  6. 1 def initialize email_start: "<", email_end: ">", model: Models::Person
  7. 6 @email_start = email_start
  8. 6 @email_end = email_end
  9. 6 @model = model
  10. end
  11. 1 def call content
  12. 6 then: 1 if content.start_with? email_start
  13. 1 model[email: content.delete_prefix(email_start).delete_suffix(email_end)]
  14. else: 5 else
  15. 5 name, email = content.split " #{email_start}"
  16. 5 then: 2 else: 3 email.delete_suffix! email_end if email
  17. 5 model[name:, email:]
  18. end
  19. end
  20. 1 private
  21. 1 attr_reader :email_start, :email_end, :model
  22. end
  23. end
  24. end

lib/gitt/parsers/tag.rb

100.0% lines covered

100.0% branches covered

24 relevant lines. 24 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "refinements/hash"
  3. 1 module Gitt
  4. 1 module Parsers
  5. # Parses raw tag information to produce a tag record.
  6. 1 class Tag
  7. 1 using Refinements::Hash
  8. 1 def initialize attributer: Attributer.new(Commands::Tag::KEY_MAP.keys),
  9. sanitizers: Sanitizers::CONTAINER,
  10. model: Models::Tag
  11. 62 @attributer = attributer
  12. 62 @sanitizers = sanitizers
  13. 62 @model = model
  14. end
  15. # :reek:TooManyStatements
  16. 1 def call content
  17. 11 attributes = attributer.call content
  18. 11 body, trailers = attributes.values_at :body, :trailers
  19. 11 sanitize attributes
  20. 11 attributes[:body] = (
  21. 11 then: 6 else: 5 trailers ? body.sub(/\n??#{Regexp.escape trailers}\n??/, "") : body
  22. ).to_s.chomp
  23. 11 model[**attributes]
  24. end
  25. 1 private
  26. 1 attr_reader :attributer, :sanitizers, :model
  27. 1 def sanitize attributes
  28. 11 attributes.transform_with! author_email: email_sanitizer,
  29. authored_at: date_sanitizer,
  30. committed_at: date_sanitizer,
  31. committer_email: email_sanitizer,
  32. trailers: trailers_sanitizer,
  33. version: version_serializer
  34. end
  35. 1 def date_sanitizer = sanitizers.fetch :date
  36. 1 def email_sanitizer = sanitizers.fetch :email
  37. 1 def trailers_sanitizer = sanitizers.fetch :trailers
  38. 1 def version_serializer = sanitizers.fetch :version
  39. end
  40. end
  41. end

lib/gitt/parsers/trailer.rb

100.0% lines covered

100.0% branches covered

15 relevant lines. 15 lines covered and 0 lines missed.
4 total branches, 4 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Gitt
  3. 1 module Parsers
  4. # Parses raw trailer data to produce a trailer record.
  5. 1 class Trailer
  6. 1 PATTERN = /
  7. \A # Start of line.
  8. (?<key>[a-zA-Z-]+) # Key.
  9. (?<delimiter>:) # Delimiter (colon).
  10. (?<space>\s?) # Space (optional).
  11. (?<value>.*?) # Value.
  12. \Z # End of line.
  13. /x
  14. 1 EMPTY = Models::Trailer[key: nil, value: nil]
  15. 1 def initialize pattern: PATTERN, model: Models::Trailer, empty: EMPTY
  16. 12 @pattern = pattern
  17. 12 @model = model
  18. 12 @empty = empty
  19. end
  20. 1 def call content
  21. 25 then: 2 else: 23 return empty if content.start_with? "#"
  22. 23 content.match(pattern)
  23. 23 then: 21 else: 2 .then { |data| data ? model[**data.named_captures(symbolize_names: true)] : empty }
  24. end
  25. 1 private
  26. 1 attr_reader :pattern, :model, :empty
  27. end
  28. end
  29. end

lib/gitt/repository.rb

100.0% lines covered

100.0% branches covered

35 relevant lines. 35 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "core"
  3. 1 module Gitt
  4. # Primary object/wrapper for processing all Git related commands.
  5. 1 class Repository
  6. COMMANDS = {
  7. 1 branch: Commands::Branch,
  8. config: Commands::Config,
  9. log: Commands::Log,
  10. tag: Commands::Tag
  11. }.freeze
  12. 1 def initialize shell: SHELL, commands: COMMANDS
  13. 28 @shell = shell
  14. 140 @commands = commands.transform_values { |command| command.new shell: }
  15. end
  16. 1 def branch(...) = commands.fetch(__method__).call(...)
  17. 1 def branch_default(...) = commands.fetch(:branch).default(...)
  18. 1 def branch_name(...) = commands.fetch(:branch).name(...)
  19. 1 def call(...) = shell.call(...)
  20. 1 def commits(...) = commands.fetch(:log).index(...)
  21. 1 def config(...) = commands.fetch(__method__).call(...)
  22. 1 def exist? = shell.call("rev-parse", "--git-dir").value_or(Core::EMPTY_STRING).chomp == ".git"
  23. 1 def get(...) = commands.fetch(:config).get(...)
  24. 1 def inspect
  25. 1 "#<#{self.class}:#{object_id} @shell=#{shell.inspect} " \
  26. "@commands=#{commands.values.map(&:class).inspect}>"
  27. end
  28. 1 def log(...) = commands.fetch(__method__).call(...)
  29. 1 def origin? = commands.fetch(:config).origin?
  30. 1 def set(...) = commands.fetch(:config).set(...)
  31. 1 def tag(...) = commands.fetch(__method__).call(...)
  32. 1 def tags(...) = commands.fetch(:tag).index(...)
  33. 1 def tag?(...) = commands.fetch(:tag).exist?(...)
  34. 1 def tag_create(...) = commands.fetch(:tag).create(...)
  35. 1 def tag_delete_local(...) = commands.fetch(:tag).delete_local(...)
  36. 1 def tag_delete_remote(...) = commands.fetch(:tag).delete_remote(...)
  37. 1 def tag_last(...) = commands.fetch(:tag).last(...)
  38. 1 def tag_local?(...) = commands.fetch(:tag).local?(...)
  39. 1 def tag_remote?(...) = commands.fetch(:tag).remote?(...)
  40. 1 def tag_show(...) = commands.fetch(:tag).show(...)
  41. 1 def tagged? = commands.fetch(:tag).tagged?
  42. 1 def tags_push(...) = commands.fetch(:tag).push(...)
  43. 1 def uncommitted(...) = commands.fetch(:log).uncommitted(...)
  44. 1 private
  45. 1 attr_reader :shell, :commands
  46. end
  47. end

lib/gitt/sanitizers/container.rb

100.0% lines covered

100.0% branches covered

3 relevant lines. 3 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Gitt
  3. 1 module Sanitizers
  4. CONTAINER = {
  5. 1 date: Date,
  6. email: Email,
  7. lines: Lines,
  8. paragraphs: Paragraphs.new,
  9. scissors: Scissors,
  10. signature: Signature,
  11. statistics: Statistics.new,
  12. trailers: Trailers.new,
  13. version: Version
  14. }.freeze
  15. end
  16. end

lib/gitt/sanitizers/date.rb

100.0% lines covered

100.0% branches covered

4 relevant lines. 4 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "core"
  3. 1 module Gitt
  4. 1 module Sanitizers
  5. 25 then: 13 else: 11 Date = -> text { text.sub(/\s.+\Z/, Core::EMPTY_STRING) if text }
  6. end
  7. end

lib/gitt/sanitizers/email.rb

100.0% lines covered

100.0% branches covered

4 relevant lines. 4 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "core"
  3. 1 module Gitt
  4. 1 module Sanitizers
  5. 25 then: 13 else: 11 Email = -> text { text.tr "<>", Core::EMPTY_STRING if text }
  6. end
  7. end

lib/gitt/sanitizers/lines.rb

100.0% lines covered

100.0% branches covered

4 relevant lines. 4 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "core"
  3. 1 module Gitt
  4. 1 module Sanitizers
  5. 69 then: 57 else: 11 Lines = -> text { text ? text.split("\n") : Core::EMPTY_ARRAY }
  6. end
  7. end

lib/gitt/sanitizers/paragraphs.rb

100.0% lines covered

100.0% branches covered

21 relevant lines. 21 lines covered and 0 lines missed.
3 total branches, 3 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "core"
  3. 1 require "refinements/array"
  4. 1 require "strscan"
  5. 1 module Gitt
  6. 1 module Sanitizers
  7. # Detects and parses paragraphs (including code blocks).
  8. 1 class Paragraphs
  9. 1 using Refinements::Array
  10. 1 PATTERN = /
  11. ( # Condition start.
  12. (?:\..*?\n)? # Optional ASCII Doc label.
  13. (?:\[.*\]\n)? # Optional ASCII Doc directive.
  14. [-_=+.*]{4} # ASCII Doc block start.
  15. [\s\S]*? # Lazy block content of any character.
  16. [-_=+.*]{4} # ASCII Doc block end.
  17. | # Or.
  18. ``` # Markdown start.
  19. [\s\S]*? # Lazy block content of any character.
  20. ``` # Markdown end.
  21. ) # Condition end.
  22. /mx
  23. 1 def initialize pattern: PATTERN, client: StringScanner
  24. 28 @pattern = pattern
  25. 28 @client = client
  26. end
  27. 1 def call(text) = scan(client.new(text.to_s))
  28. 1 private
  29. 1 attr_reader :pattern, :client
  30. # :reek:FeatureEnvy
  31. 1 def scan scanner, collection = []
  32. 60 body: 67 until scanner.eos?
  33. 67 match = scanner.scan_until pattern
  34. 67 else: 24 then: 43 break collection << scanner.string[scanner.rest].tap(&:strip!).split("\n\n") unless match
  35. 24 collection << scanner.pre_match.strip
  36. 24 collection << scanner.captures
  37. end
  38. 60 collection.tap(&:flatten!).tap(&:compress!)
  39. end
  40. end
  41. end
  42. end

lib/gitt/sanitizers/scissors.rb

100.0% lines covered

100.0% branches covered

4 relevant lines. 4 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "core"
  3. 1 module Gitt
  4. 1 module Sanitizers
  5. 36 then: 34 else: 1 Scissors = -> text { text.sub(/^#\s-.+\s>8\s-.+/m, Core::EMPTY_STRING) if text }
  6. end
  7. end

lib/gitt/sanitizers/signature.rb

100.0% lines covered

100.0% branches covered

13 relevant lines. 13 lines covered and 0 lines missed.
9 total branches, 9 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Gitt
  3. 1 module Sanitizers
  4. 1 Signature = lambda do |text|
  5. 42 when: 1 case text
  6. 1 when: 1 when "B" then "Bad"
  7. 1 when: 1 when "E" then "Error"
  8. 1 when: 23 when "G" then "Good"
  9. 23 when: 1 when "N" then "None"
  10. 1 when: 1 when "R" then "Revoked"
  11. 1 when: 1 when "U" then "Unknown"
  12. 1 when: 1 when "X" then "Expired"
  13. 1 else: 12 when "Y" then "Expired Key"
  14. 12 else "Invalid"
  15. end
  16. end
  17. end
  18. end

lib/gitt/sanitizers/statistics.rb

100.0% lines covered

100.0% branches covered

20 relevant lines. 20 lines covered and 0 lines missed.
6 total branches, 6 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Gitt
  3. 1 module Sanitizers
  4. # Converts raw text into a statistics hash.
  5. 1 class Statistics
  6. 1 EMPTY = {files_changed: 0, insertions: 0, deletions: 0}.freeze
  7. 1 PATTERN = /
  8. (?<total>\d+) # Total capture group.
  9. \s # Space delimiter.
  10. (?<kind>file|insertion|deletion) # Kind capture group.
  11. /x
  12. 1 def self.update attributes, kind, total
  13. 56 when: 20 case kind
  14. 20 when: 18 when "file" then attributes[:files_changed] = total
  15. 18 when: 17 when "insertion" then attributes[:insertions] = total
  16. 17 else: 1 when "deletion" then attributes[:deletions] = total
  17. 1 else fail StandardError, "Invalid kind: #{kind.inspect}."
  18. end
  19. end
  20. 1 def initialize attributes = EMPTY, pattern: PATTERN
  21. 7 @attributes = attributes
  22. 7 @pattern = pattern
  23. end
  24. 1 def call text
  25. 39 else: 22 then: 17 return attributes unless text
  26. 22 text.scan(pattern).each.with_object(attributes.dup) do |(number, kind), aggregate|
  27. 52 self.class.update aggregate, kind, number.to_i
  28. end
  29. end
  30. 1 private
  31. 1 attr_reader :attributes, :pattern
  32. end
  33. end
  34. end

lib/gitt/sanitizers/trailers.rb

100.0% lines covered

100.0% branches covered

10 relevant lines. 10 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Gitt
  3. 1 module Sanitizers
  4. # Sanitizes content by turning it into an array of trailer records.
  5. 1 class Trailers
  6. 1 def initialize parser: Parsers::Trailer.new
  7. 5 @parser = parser
  8. end
  9. 1 def call text
  10. 48 String(text).split("\n")
  11. 18 .map { |line| parser.call line }
  12. .reject(&:empty?)
  13. end
  14. 1 private
  15. 1 attr_reader :parser
  16. end
  17. end
  18. end

lib/gitt/sanitizers/version.rb

100.0% lines covered

100.0% branches covered

4 relevant lines. 4 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "core"
  3. 1 module Gitt
  4. 1 module Sanitizers
  5. 14 then: 7 else: 6 Version = -> text { text.delete_prefix "refs/tags/" if text }
  6. end
  7. end

lib/gitt/shell.rb

100.0% lines covered

100.0% branches covered

13 relevant lines. 13 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "dry/monads"
  3. 1 require "open3"
  4. 1 module Gitt
  5. # A low-level shell client.
  6. 1 class Shell
  7. 1 include Dry::Monads[:result]
  8. 1 def initialize client: Open3
  9. 7 @client = client
  10. end
  11. 1 def call(*all, **)
  12. 489 environment, arguments = all.partition { it.is_a? Hash }
  13. 120 client.capture3(*environment, "git", *arguments, **).then do |stdout, stderr, status|
  14. 120 then: 104 else: 16 status.success? ? Success(stdout) : Failure(stderr)
  15. end
  16. end
  17. 1 private
  18. 1 attr_reader :client
  19. end
  20. end

lib/gitt/trailable.rb

100.0% lines covered

100.0% branches covered

13 relevant lines. 13 lines covered and 0 lines missed.
2 total branches, 2 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "dry/monads"
  3. 1 module Gitt
  4. # Provides shared behavior for objects that have trailers.
  5. 1 module Trailable
  6. 1 include Dry::Monads[:result]
  7. 1 def find_trailer key
  8. 24 trailers.find { |trailer| trailer.key == key }
  9. .then do |trailer|
  10. 12 then: 6 else: 6 return Success trailer if trailer
  11. 6 Failure "Unable to find trailer for key: #{key.inspect}."
  12. end
  13. end
  14. 1 def find_trailers key
  15. 48 trailers.select { |trailer| trailer.key == key }
  16. 12 .then { |trailers| Success trailers }
  17. end
  18. 1 def trailer_value_for(key) = find_trailer(key).fmap(&:value)
  19. 7 def trailer_values_for(key) = find_trailers(key).fmap { |trailers| trailers.map(&:value) }
  20. end
  21. end