gitt 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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