gitt 1.0.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,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+ require "tempfile"
5
+
6
+ module Gitt
7
+ module Commands
8
+ # A Git tag command wrapper.
9
+ class Tag
10
+ include Dry::Monads[:result]
11
+
12
+ KEY_MAP = {
13
+ author_email: "%(*authoremail)",
14
+ author_name: "%(*authorname)",
15
+ authored_at: "%(*authordate:raw)",
16
+ authored_relative_at: "%(*authordate:relative)",
17
+ committed_at: "%(*committerdate:raw)",
18
+ committed_relative_at: "%(*committerdate:relative)",
19
+ committer_email: "%(*committeremail)",
20
+ committer_name: "%(*committername)",
21
+ body: "%(body)",
22
+ sha: "%(objectname)",
23
+ subject: "%(subject)",
24
+ version: "%(refname)"
25
+ }.freeze
26
+
27
+ def initialize shell: SHELL, key_map: KEY_MAP, parser: Parsers::Tag.new
28
+ @shell = shell
29
+ @key_map = key_map
30
+ @parser = parser
31
+ end
32
+
33
+ def call(*arguments) = shell.call "tag", *arguments
34
+
35
+ def create version, body = EMPTY_STRING, *flags
36
+ return Failure "Unable to create Git tag without version." unless version
37
+ return Failure "Tag exists: #{version}." if exist? version
38
+
39
+ Tempfile.open "git_plus" do |file|
40
+ file.write body
41
+ write version, file.tap(&:rewind), *flags
42
+ end
43
+ end
44
+
45
+ def exist?(version) = local?(version) || remote?(version)
46
+
47
+ def index *arguments
48
+ arguments.prepend(pretty_format, "--list")
49
+ .then { |flags| call(*flags) }
50
+ .fmap { |content| String(content).scrub("?").split %("\n") }
51
+ .fmap { |entries| build_records entries }
52
+ end
53
+
54
+ def last
55
+ shell.call("describe", "--abbrev=0", "--tags", "--always")
56
+ .fmap(&:strip)
57
+ .or { |error| Failure error.delete_prefix("fatal: ").chomp }
58
+ end
59
+
60
+ def local?(version) = call("--list", version).value_or(EMPTY_STRING).match?(/\A#{version}\Z/)
61
+
62
+ def push = shell.call "push", "--tags"
63
+
64
+ def remote? version
65
+ shell.call("ls-remote", "--tags", "origin", version)
66
+ .value_or(EMPTY_STRING)
67
+ .match?(%r(.+tags/#{version}\Z))
68
+ end
69
+
70
+ def show version
71
+ call(pretty_format, "--list", version).fmap { |content| parser.call content }
72
+ end
73
+
74
+ def tagged? = !call.value_or(EMPTY_STRING).empty?
75
+
76
+ private
77
+
78
+ attr_reader :shell, :key_map, :parser
79
+
80
+ def pretty_format
81
+ key_map.reduce("") { |content, (key, value)| content + "<#{key}>#{value}</#{key}>%n" }
82
+ .then { |format| %(--format="#{format}") }
83
+ end
84
+
85
+ def build_records(entries) = entries.map { |entry| parser.call entry }
86
+
87
+ def write version, file, *flags
88
+ arguments = ["--annotate", version, "--cleanup", "verbatim", *flags, "--file", file.path]
89
+ call(*arguments).fmap { version }
90
+ .or { Failure "Unable to create tag: #{version}." }
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitt
4
+ module Models
5
+ # Represents commit details.
6
+ 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
+ :lines,
19
+ :raw,
20
+ :sha,
21
+ :signature,
22
+ :subject,
23
+ :trailers,
24
+ keyword_init: true
25
+ ) do
26
+ def initialize *arguments
27
+ super
28
+ freeze
29
+ end
30
+
31
+ def amend? = subject.match?(/\Aamend!\s/)
32
+
33
+ def fixup? = subject.match?(/\Afixup!\s/)
34
+
35
+ def squash? = subject.match?(/\Asquash!\s/)
36
+
37
+ def prefix? = amend? || fixup? || squash?
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitt
4
+ module Models
5
+ # Represents a person within a repository.
6
+ Person = Struct.new :name, :delimiter, :email, keyword_init: true do
7
+ def self.for(string, parser: Parsers::Person.new) = parser.call string
8
+
9
+ def initialize *arguments
10
+ super
11
+ freeze
12
+ end
13
+
14
+ def to_s = values.join
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitt
4
+ module Models
5
+ # Represents tag details.
6
+ Tag = Struct.new(
7
+ :author_email,
8
+ :author_name,
9
+ :authored_at,
10
+ :authored_relative_at,
11
+ :body,
12
+ :committed_at,
13
+ :committed_relative_at,
14
+ :committer_email,
15
+ :committer_name,
16
+ :sha,
17
+ :subject,
18
+ :version,
19
+ keyword_init: true
20
+ ) do
21
+ def initialize *arguments
22
+ super
23
+ freeze
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitt
4
+ module Models
5
+ # Represents commit trailer details.
6
+ Trailer = Struct.new :key, :delimiter, :space, :value, keyword_init: true do
7
+ def self.for(string, parser: Parsers::Trailer.new) = parser.call string
8
+
9
+ def initialize *arguments
10
+ super
11
+ freeze
12
+ end
13
+
14
+ def to_s = values.join
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitt
4
+ module Parsers
5
+ # Extracts attributes from XML formatted content.
6
+ class Attributer
7
+ def self.with(...) = new(...)
8
+
9
+ def initialize keys = []
10
+ @keys = keys
11
+ end
12
+
13
+ def call content
14
+ scrub = String(content).scrub "?"
15
+
16
+ keys.reduce({}) do |attributes, key|
17
+ attributes.merge key => scrub[%r(<#{key}>(?<value>.*?)</#{key}>)m, :value]
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :keys
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "refinements/hashes"
4
+
5
+ module Gitt
6
+ module Parsers
7
+ # Parses raw commit information to produce a commit record.
8
+ class Commit
9
+ using Refinements::Hashes
10
+
11
+ def self.call(...) = new.call(...)
12
+
13
+ def initialize attributer: Attributer.with(Commands::Log::KEY_MAP.keys),
14
+ sanitizers: Sanitizers::CONTAINER,
15
+ model: Models::Commit
16
+ @attributer = attributer
17
+ @sanitizers = sanitizers
18
+ @model = model
19
+ end
20
+
21
+ def call content
22
+ attributer.call(content)
23
+ .then { |attributes| process attributes }
24
+ .then { |attributes| model[attributes] }
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :attributer, :sanitizers, :model
30
+
31
+ # :reek:TooManyStatements
32
+ def process attributes
33
+ body, trailers = attributes.values_at :body, :trailers
34
+
35
+ attributes.transform_with! body: scissors_sanitizer,
36
+ signature: signature_sanitizer,
37
+ trailers: trailers_sanitizer
38
+
39
+ attributes[:body] =
40
+ (trailers ? body.sub(/\n??#{Regexp.escape trailers}\n??/, "") : body).chomp
41
+
42
+ private_methods.grep(/\Aprocess_/).sort.each { |method| __send__ method, attributes }
43
+ attributes
44
+ end
45
+
46
+ # :reek:FeatureEnvy
47
+ def process_body_lines attributes
48
+ attributes[:body_lines] = lines_sanitizer.call attributes[:body]
49
+ end
50
+
51
+ # :reek:FeatureEnvy
52
+ def process_body_paragraphs attributes
53
+ attributes[:body_paragraphs] = paragraphs_sanitizer.call attributes[:body]
54
+ end
55
+
56
+ # :reek:FeatureEnvy
57
+ def process_lines attributes
58
+ attributes[:lines] = lines_sanitizer.call attributes[:raw]
59
+ end
60
+
61
+ def lines_sanitizer = sanitizers.fetch :lines
62
+
63
+ def paragraphs_sanitizer = sanitizers.fetch :paragraphs
64
+
65
+ def scissors_sanitizer = sanitizers.fetch :scissors
66
+
67
+ def signature_sanitizer = sanitizers.fetch :signature
68
+
69
+ def trailers_sanitizer = sanitizers.fetch :trailers
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitt
4
+ module Parsers
5
+ # Parses raw trailer data to produce a trailer record.
6
+ class Person
7
+ PATTERN = /
8
+ \A # Start of line.
9
+ (?<name>.*?) # Name (smallest possible).
10
+ (?<delimiter>\s?) # Space delimiter (optional).
11
+ (?<email><.+>)? # Collaborator email (optional).
12
+ \Z # End of line.
13
+ /x
14
+
15
+ def initialize model: Models::Person, pattern: PATTERN
16
+ @pattern = pattern
17
+ @model = model
18
+ end
19
+
20
+ def call(content) = model[content.match(pattern).named_captures]
21
+
22
+ private
23
+
24
+ attr_reader :pattern, :model
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "refinements/hashes"
4
+
5
+ module Gitt
6
+ module Parsers
7
+ # Parses raw tag information to produce a tag record.
8
+ class Tag
9
+ using Refinements::Hashes
10
+
11
+ def initialize attributer: Attributer.with(Commands::Tag::KEY_MAP.keys),
12
+ sanitizers: Sanitizers::CONTAINER,
13
+ model: Models::Tag
14
+ @attributer = attributer
15
+ @sanitizers = sanitizers
16
+ @model = model
17
+ end
18
+
19
+ def call content
20
+ attributes = attributer.call content
21
+ attributes.transform_with! author_email: email_sanitizer,
22
+ authored_at: date_sanitizer,
23
+ committed_at: date_sanitizer,
24
+ committer_email: email_sanitizer,
25
+ version: -> value { value.delete_prefix "refs/tags/" if value }
26
+ model[attributes]
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :attributer, :sanitizers, :model
32
+
33
+ def date_sanitizer = sanitizers.fetch(:date)
34
+
35
+ def email_sanitizer = sanitizers.fetch(:email)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitt
4
+ module Parsers
5
+ # Parses raw trailer data to produce a trailer record.
6
+ class Trailer
7
+ PATTERN = /
8
+ (?<key>\A.+) # Key (anchored to start of line).
9
+ (?<delimiter>:) # Colon delimiter.
10
+ (?<space>\s?) # Space (optional).
11
+ (?<value>.*?) # Value.
12
+ \Z # End of line.
13
+ /x
14
+
15
+ def initialize model: Models::Trailer, pattern: PATTERN
16
+ @pattern = pattern
17
+ @model = model
18
+ end
19
+
20
+ def call content
21
+ content.match(pattern)
22
+ .then { |data| data ? data.named_captures : {} }
23
+ .then { |attributes| model[attributes] }
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :pattern, :model
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitt
4
+ # Primary object/wrapper for processing all Git related commands.
5
+ # :reek:TooManyMethods
6
+ class Repository
7
+ COMMANDS = {
8
+ branch: Commands::Branch,
9
+ config: Commands::Config,
10
+ log: Commands::Log,
11
+ tag: Commands::Tag
12
+ }.freeze
13
+
14
+ def initialize shell: SHELL, commands: COMMANDS
15
+ @shell = shell
16
+ @commands = commands.transform_values { |command| command.new shell: }
17
+ end
18
+
19
+ def branch(...) = commands.fetch(__method__).call(...)
20
+
21
+ def branch_default = commands.fetch(:branch).default
22
+
23
+ def branch_name = commands.fetch(:branch).name
24
+
25
+ def call(...) = shell.call(...)
26
+
27
+ def commits(...) = commands.fetch(:log).index(...)
28
+
29
+ def config(...) = commands.fetch(__method__).call(...)
30
+
31
+ def exist? = shell.call("rev-parse", "--git-dir").value_or(EMPTY_STRING).chomp == ".git"
32
+
33
+ def get(...) = commands.fetch(:config).get(...)
34
+
35
+ def log(...) = commands.fetch(__method__).call(...)
36
+
37
+ def origin? = commands.fetch(:config).origin?
38
+
39
+ def set(...) = commands.fetch(:config).set(...)
40
+
41
+ def tag(...) = commands.fetch(__method__).call(...)
42
+
43
+ def tags(...) = commands.fetch(:tag).index(...)
44
+
45
+ def tag?(...) = commands.fetch(:tag).exist?(...)
46
+
47
+ def tag_create(...) = commands.fetch(:tag).create(...)
48
+
49
+ def tag_last = commands.fetch(:tag).last
50
+
51
+ def tag_local?(...) = commands.fetch(:tag).local?(...)
52
+
53
+ def tag_remote?(...) = commands.fetch(:tag).remote?(...)
54
+
55
+ def tag_show(...) = commands.fetch(:tag).show(...)
56
+
57
+ def tagged? = commands.fetch(:tag).tagged?
58
+
59
+ def tags_push = commands.fetch(:tag).push
60
+
61
+ def uncommitted(...) = commands.fetch(:log).uncommitted(...)
62
+
63
+ private
64
+
65
+ attr_reader :shell, :commands
66
+ end
67
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitt
4
+ module Sanitizers
5
+ CONTAINER = {
6
+ date: Date,
7
+ email: Email,
8
+ lines: Lines,
9
+ paragraphs: Paragraphs,
10
+ scissors: Scissors,
11
+ signature: Signature,
12
+ trailers: Trailers.new
13
+ }.freeze
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitt
4
+ module Sanitizers
5
+ Date = -> value { value.sub(/\s.+\Z/, "") if value }
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitt
4
+ module Sanitizers
5
+ Email = -> value { value.tr "<>", "" if value }
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitt
4
+ module Sanitizers
5
+ Lines = -> value { value ? value.split("\n") : EMPTY_ARRAY }
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitt
4
+ module Sanitizers
5
+ Paragraphs = -> value { value ? value.split("\n\n").map(&:chomp) : EMPTY_ARRAY }
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitt
4
+ module Sanitizers
5
+ Scissors = -> value { value.sub(/^#\s-.+\s>8\s-.+/m, "") if value }
6
+ end
7
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitt
4
+ module Sanitizers
5
+ Signature = lambda do |value|
6
+ case value
7
+ when "B" then "Bad"
8
+ when "E" then "Error"
9
+ when "G" then "Good"
10
+ when "N" then "None"
11
+ when "R" then "Revoked"
12
+ when "U" then "Unknown"
13
+ when "X" then "Expired"
14
+ when "Y" then "Expired Key"
15
+ else "Invalid"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitt
4
+ module Sanitizers
5
+ # Sanitizes content by turning it into an array of trailer records.
6
+ class Trailers
7
+ def initialize parser: Parsers::Trailer.new
8
+ @parser = parser
9
+ end
10
+
11
+ def call(value) = String(value).split("\n").map { |text| parser.call text }
12
+
13
+ private
14
+
15
+ attr_reader :parser
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.shared_context "with Git commit" do
4
+ let :git_commit do
5
+ Gitt::Models::Commit[
6
+ author_email: "test@example.com",
7
+ author_name: "Test User",
8
+ authored_relative_at: "1 day ago",
9
+ body: "",
10
+ raw: "",
11
+ sha: "180dec7d8ae8cbe3565a727c63c2111e49e0b737",
12
+ subject: "Added documentation",
13
+ trailers: ""
14
+ ]
15
+ end
16
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.shared_context "with Git repository" do
4
+ include_context "with temporary directory"
5
+
6
+ using Refinements::Pathnames
7
+
8
+ let(:git_repo_dir) { temp_dir.join "test" }
9
+
10
+ around do |example|
11
+ git_repo_dir.make_dir.change_dir do |path|
12
+ path.join("README.md").touch
13
+
14
+ `git init`
15
+ `git config user.name "Test User"`
16
+ `git config user.email "test@example.com"`
17
+ `git config core.hooksPath /dev/null`
18
+ `git config commit.gpgSign false`
19
+ `git config tag.gpgSign false`
20
+ `git config remote.origin.url https://github.com/bkuhlmann/test`
21
+ `git add --all .`
22
+ `git commit --all --message "Added documentation"`
23
+ end
24
+
25
+ example.run
26
+
27
+ temp_dir.remove_tree
28
+ end
29
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.shared_context "with temporary directory" do
4
+ using Refinements::Pathnames
5
+
6
+ let(:temp_dir) { Bundler.root.join "tmp", "rspec" }
7
+
8
+ around do |example|
9
+ temp_dir.make_path
10
+ example.run
11
+ temp_dir.remove_tree
12
+ end
13
+ end
data/lib/gitt/shell.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/monads"
4
+ require "open3"
5
+
6
+ module Gitt
7
+ # A low-level shell client.
8
+ class Shell
9
+ include Dry::Monads[:result]
10
+
11
+ def initialize client: Open3
12
+ @client = client
13
+ end
14
+
15
+ def call(...)
16
+ client.capture3("git", ...).then do |stdout, stderr, status|
17
+ status.success? ? Success(stdout) : Failure(stderr)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :client
24
+ end
25
+ end
data/lib/gitt.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+
5
+ Zeitwerk::Loader.for_gem.then do |loader|
6
+ loader.inflector.inflect "container" => "CONTAINER"
7
+ loader.ignore "#{__dir__}/gitt/shared_contexts"
8
+ loader.setup
9
+ end
10
+
11
+ # Main namespace.
12
+ module Gitt
13
+ EMPTY_STRING = ""
14
+ EMPTY_ARRAY = [].freeze
15
+ SHELL = Shell.new.freeze
16
+ end
data.tar.gz.sig ADDED
Binary file