spoom 1.1.16 → 1.2.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.
- checksums.yaml +4 -4
- data/lib/spoom/cli/bump.rb +3 -11
- data/lib/spoom/cli/config.rb +4 -3
- data/lib/spoom/cli/coverage.rb +14 -15
- data/lib/spoom/cli/helper.rb +11 -21
- data/lib/spoom/cli/lsp.rb +4 -2
- data/lib/spoom/cli/run.rb +3 -7
- data/lib/spoom/cli.rb +4 -7
- data/lib/spoom/context/bundle.rb +58 -0
- data/lib/spoom/context/exec.rb +50 -0
- data/lib/spoom/context/file_system.rb +93 -0
- data/lib/spoom/context/git.rb +121 -0
- data/lib/spoom/context/sorbet.rb +136 -0
- data/lib/spoom/context.rb +16 -200
- data/lib/spoom/coverage.rb +20 -35
- data/lib/spoom/sorbet.rb +0 -119
- data/lib/spoom/timeline.rb +5 -8
- data/lib/spoom/version.rb +1 -1
- data/lib/spoom.rb +0 -52
- metadata +8 -4
- data/lib/spoom/git.rb +0 -109
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 323ebe199d0efed57d5e92af12eb377c3c5af35e468c83db851b27f86b44117b
|
4
|
+
data.tar.gz: c0fb24e2af1af3c0e460bb7c06ba9f3cff9cda471ec829ba00742b40dd67ab84
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: faa5f86d378dd4cf56aa6e680cdee5717c59069b464b823bebfc63e7c8a7a40aeb7a75d86ec4dd0c69e025c4ad397eff740fdfa2ac134f49868736197836790a
|
7
|
+
data.tar.gz: 67f7d4bc72282ff4bea575e85c2eed80f7195f94d3b518e3a84af57085bed90c757aedab5cfe33fb6d14dc96b62f21c6e8aa8cb56b79d4e9b148318a7c713e98
|
data/lib/spoom/cli/bump.rb
CHANGED
@@ -47,8 +47,7 @@ module Spoom
|
|
47
47
|
option :sorbet_options, type: :string, default: "", desc: "Pass options to Sorbet"
|
48
48
|
sig { params(directory: String).void }
|
49
49
|
def bump(directory = ".")
|
50
|
-
|
51
|
-
|
50
|
+
context = context_requiring_sorbet!
|
52
51
|
from = options[:from]
|
53
52
|
to = options[:to]
|
54
53
|
force = options[:force]
|
@@ -77,7 +76,7 @@ module Spoom
|
|
77
76
|
directory = File.expand_path(directory)
|
78
77
|
files_to_bump = Sorbet::Sigils.files_with_sigil_strictness(directory, from)
|
79
78
|
|
80
|
-
files_from_config =
|
79
|
+
files_from_config = context.srb_files.map { |file| File.expand_path(file) }
|
81
80
|
files_to_bump.select! { |file| files_from_config.include?(file) }
|
82
81
|
|
83
82
|
if only
|
@@ -102,10 +101,9 @@ module Spoom
|
|
102
101
|
|
103
102
|
error_url_base = Spoom::Sorbet::Errors::DEFAULT_ERROR_URL_BASE
|
104
103
|
result = begin
|
105
|
-
|
104
|
+
T.unsafe(context).srb_tc(
|
106
105
|
*options[:sorbet_options].split(" "),
|
107
106
|
"--error-url-base=#{error_url_base}",
|
108
|
-
path: exec_path,
|
109
107
|
capture_err: true,
|
110
108
|
sorbet_bin: options[:sorbet],
|
111
109
|
)
|
@@ -195,12 +193,6 @@ module Spoom
|
|
195
193
|
def undo_changes(files, from_strictness)
|
196
194
|
Sorbet::Sigils.change_sigil_in_files(files, from_strictness)
|
197
195
|
end
|
198
|
-
|
199
|
-
def config_files(path: ".")
|
200
|
-
config = sorbet_config
|
201
|
-
files = Sorbet.srb_files(config, path: path)
|
202
|
-
files.map { |file| File.expand_path(file) }
|
203
|
-
end
|
204
196
|
end
|
205
197
|
end
|
206
198
|
end
|
data/lib/spoom/cli/config.rb
CHANGED
@@ -13,10 +13,11 @@ module Spoom
|
|
13
13
|
|
14
14
|
desc "show", "Show Sorbet config"
|
15
15
|
def show
|
16
|
-
|
17
|
-
config = sorbet_config
|
16
|
+
context = context_requiring_sorbet!
|
17
|
+
config = context.sorbet_config
|
18
|
+
config_path = Pathname.new("#{exec_path}/#{Spoom::Sorbet::CONFIG_PATH}").cleanpath
|
18
19
|
|
19
|
-
say("Found Sorbet config at `#{
|
20
|
+
say("Found Sorbet config at `#{config_path}`.")
|
20
21
|
|
21
22
|
say("\nPaths typechecked:")
|
22
23
|
if config.paths.empty?
|
data/lib/spoom/cli/coverage.rb
CHANGED
@@ -18,11 +18,10 @@ module Spoom
|
|
18
18
|
option :rbi, type: :boolean, default: true, desc: "Include RBI files in metrics"
|
19
19
|
option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
|
20
20
|
def snapshot
|
21
|
-
|
22
|
-
path = exec_path
|
21
|
+
context = context_requiring_sorbet!
|
23
22
|
sorbet = options[:sorbet]
|
24
23
|
|
25
|
-
snapshot = Spoom::Coverage.snapshot(
|
24
|
+
snapshot = Spoom::Coverage.snapshot(context, rbi: options[:rbi], sorbet_bin: sorbet)
|
26
25
|
snapshot.print
|
27
26
|
|
28
27
|
save_dir = options[:save]
|
@@ -41,19 +40,19 @@ module Spoom
|
|
41
40
|
option :bundle_install, type: :boolean, desc: "Execute `bundle install` before collecting metrics"
|
42
41
|
option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
|
43
42
|
def timeline
|
44
|
-
|
43
|
+
context = context_requiring_sorbet!
|
45
44
|
path = exec_path
|
46
45
|
sorbet = options[:sorbet]
|
47
46
|
|
48
|
-
ref_before =
|
49
|
-
ref_before =
|
47
|
+
ref_before = context.git_current_branch
|
48
|
+
ref_before = context.git_last_commit&.sha unless ref_before
|
50
49
|
unless ref_before
|
51
50
|
say_error("Not in a git repository")
|
52
51
|
say_error("\nSpoom needs to checkout into your previous commits to build the timeline.", status: nil)
|
53
52
|
exit(1)
|
54
53
|
end
|
55
54
|
|
56
|
-
unless
|
55
|
+
unless context.git_workdir_clean?
|
57
56
|
say_error("Uncommited changes")
|
58
57
|
say_error(<<~ERR, status: nil)
|
59
58
|
|
@@ -71,12 +70,12 @@ module Spoom
|
|
71
70
|
to = parse_time(options[:to], "--to")
|
72
71
|
|
73
72
|
unless from
|
74
|
-
intro_commit =
|
73
|
+
intro_commit = context.sorbet_intro_commit
|
75
74
|
intro_commit = T.must(intro_commit) # we know it's in there since in_sorbet_project!
|
76
75
|
from = intro_commit.time
|
77
76
|
end
|
78
77
|
|
79
|
-
timeline = Spoom::Timeline.new(from, to
|
78
|
+
timeline = Spoom::Timeline.new(context, from, to)
|
80
79
|
ticks = timeline.ticks
|
81
80
|
|
82
81
|
if ticks.empty?
|
@@ -87,17 +86,17 @@ module Spoom
|
|
87
86
|
ticks.each_with_index do |commit, i|
|
88
87
|
say("Analyzing commit `#{commit.sha}` - #{commit.time.strftime("%F")} (#{i + 1} / #{ticks.size})")
|
89
88
|
|
90
|
-
|
89
|
+
context.git_checkout!(ref: commit.sha)
|
91
90
|
|
92
91
|
snapshot = T.let(nil, T.nilable(Spoom::Coverage::Snapshot))
|
93
92
|
if options[:bundle_install]
|
94
93
|
Bundler.with_unbundled_env do
|
95
94
|
next unless bundle_install(path, commit.sha)
|
96
95
|
|
97
|
-
snapshot = Spoom::Coverage.snapshot(
|
96
|
+
snapshot = Spoom::Coverage.snapshot(context, sorbet_bin: sorbet)
|
98
97
|
end
|
99
98
|
else
|
100
|
-
snapshot = Spoom::Coverage.snapshot(
|
99
|
+
snapshot = Spoom::Coverage.snapshot(context, sorbet_bin: sorbet)
|
101
100
|
end
|
102
101
|
next unless snapshot
|
103
102
|
|
@@ -110,7 +109,7 @@ module Spoom
|
|
110
109
|
File.write(file, snapshot.to_json)
|
111
110
|
say(" Snapshot data saved under `#{file}`\n\n")
|
112
111
|
end
|
113
|
-
|
112
|
+
context.git_checkout!(ref: ref_before)
|
114
113
|
end
|
115
114
|
|
116
115
|
desc "report", "Produce a typing coverage report"
|
@@ -141,7 +140,7 @@ module Spoom
|
|
141
140
|
default: Spoom::Coverage::D3::COLOR_STRONG,
|
142
141
|
desc: "Color used for typed: strong"
|
143
142
|
def report
|
144
|
-
|
143
|
+
context = context_requiring_sorbet!
|
145
144
|
|
146
145
|
data_dir = options[:data]
|
147
146
|
files = Dir.glob("#{data_dir}/*.json")
|
@@ -163,7 +162,7 @@ module Spoom
|
|
163
162
|
strong: options[:color_strong],
|
164
163
|
)
|
165
164
|
|
166
|
-
report = Spoom::Coverage.report(snapshots, palette: palette
|
165
|
+
report = Spoom::Coverage.report(context, snapshots, palette: palette)
|
167
166
|
file = options[:file]
|
168
167
|
File.write(file, report.html)
|
169
168
|
say("Report generated under `#{file}`")
|
data/lib/spoom/cli/helper.rb
CHANGED
@@ -46,25 +46,25 @@ module Spoom
|
|
46
46
|
$stderr.flush
|
47
47
|
end
|
48
48
|
|
49
|
-
#
|
50
|
-
sig { returns(
|
51
|
-
def
|
52
|
-
|
49
|
+
# Returns the context at `--path` (by default the current working directory)
|
50
|
+
sig { returns(Context) }
|
51
|
+
def context
|
52
|
+
@context ||= T.let(Context.new(exec_path), T.nilable(Context))
|
53
53
|
end
|
54
54
|
|
55
|
-
#
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
unless in_sorbet_project?
|
55
|
+
# Raise if `spoom` is not ran inside a context with a `sorbet/config` file
|
56
|
+
sig { returns(Context) }
|
57
|
+
def context_requiring_sorbet!
|
58
|
+
context = self.context
|
59
|
+
unless context.has_sorbet_config?
|
61
60
|
say_error(
|
62
|
-
"not in a Sorbet project (`#{
|
61
|
+
"not in a Sorbet project (`#{Spoom::Sorbet::CONFIG_PATH}` not found)\n\n" \
|
63
62
|
"When running spoom from another path than the project's root, " \
|
64
63
|
"use `--path PATH` to specify the path to the root.",
|
65
64
|
)
|
66
65
|
Kernel.exit(1)
|
67
66
|
end
|
67
|
+
context
|
68
68
|
end
|
69
69
|
|
70
70
|
# Return the path specified through `--path`
|
@@ -73,16 +73,6 @@ module Spoom
|
|
73
73
|
options[:path]
|
74
74
|
end
|
75
75
|
|
76
|
-
sig { returns(String) }
|
77
|
-
def sorbet_config_file
|
78
|
-
Pathname.new("#{exec_path}/#{Spoom::Sorbet::CONFIG_PATH}").cleanpath.to_s
|
79
|
-
end
|
80
|
-
|
81
|
-
sig { returns(Sorbet::Config) }
|
82
|
-
def sorbet_config
|
83
|
-
Sorbet::Config.parse_file(sorbet_config_file)
|
84
|
-
end
|
85
|
-
|
86
76
|
# Colors
|
87
77
|
|
88
78
|
# Color used to highlight expressions in backticks
|
data/lib/spoom/cli/lsp.rb
CHANGED
@@ -14,7 +14,8 @@ module Spoom
|
|
14
14
|
|
15
15
|
desc "interactive", "Interactive LSP mode"
|
16
16
|
def show
|
17
|
-
|
17
|
+
context_requiring_sorbet!
|
18
|
+
|
18
19
|
lsp = lsp_client
|
19
20
|
# TODO: run interactive mode
|
20
21
|
puts lsp
|
@@ -111,7 +112,8 @@ module Spoom
|
|
111
112
|
|
112
113
|
no_commands do
|
113
114
|
def lsp_client
|
114
|
-
|
115
|
+
context_requiring_sorbet!
|
116
|
+
|
115
117
|
path = exec_path
|
116
118
|
client = Spoom::LSP::Client.new(
|
117
119
|
Spoom::Sorbet::BIN_PATH,
|
data/lib/spoom/cli/run.rb
CHANGED
@@ -24,9 +24,7 @@ module Spoom
|
|
24
24
|
option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
|
25
25
|
option :sorbet_options, type: :string, default: "", desc: "Pass options to Sorbet"
|
26
26
|
def tc(*paths_to_select)
|
27
|
-
|
28
|
-
|
29
|
-
path = exec_path
|
27
|
+
context = context_requiring_sorbet!
|
30
28
|
limit = options[:limit]
|
31
29
|
sort = options[:sort]
|
32
30
|
code = options[:code]
|
@@ -36,9 +34,8 @@ module Spoom
|
|
36
34
|
sorbet = options[:sorbet]
|
37
35
|
|
38
36
|
unless limit || code || sort
|
39
|
-
result = T.unsafe(
|
37
|
+
result = T.unsafe(context).srb_tc(
|
40
38
|
*options[:sorbet_options].split(" "),
|
41
|
-
path: path,
|
42
39
|
capture_err: false,
|
43
40
|
sorbet_bin: sorbet,
|
44
41
|
)
|
@@ -48,10 +45,9 @@ module Spoom
|
|
48
45
|
end
|
49
46
|
|
50
47
|
error_url_base = Spoom::Sorbet::Errors::DEFAULT_ERROR_URL_BASE
|
51
|
-
result = T.unsafe(
|
48
|
+
result = T.unsafe(context).srb_tc(
|
52
49
|
*options[:sorbet_options].split(" "),
|
53
50
|
"--error-url-base=#{error_url_base}",
|
54
|
-
path: path,
|
55
51
|
capture_err: true,
|
56
52
|
sorbet_bin: sorbet,
|
57
53
|
)
|
data/lib/spoom/cli.rb
CHANGED
@@ -41,23 +41,20 @@ module Spoom
|
|
41
41
|
option :tree, type: :boolean, default: true, desc: "Display list as an indented tree"
|
42
42
|
option :rbi, type: :boolean, default: true, desc: "Show RBI files"
|
43
43
|
def files
|
44
|
-
|
45
|
-
|
46
|
-
path = exec_path
|
47
|
-
config = sorbet_config
|
48
|
-
files = Spoom::Sorbet.srb_files(config, path: path)
|
44
|
+
context = context_requiring_sorbet!
|
45
|
+
files = context.srb_files
|
49
46
|
|
50
47
|
unless options[:rbi]
|
51
48
|
files = files.reject { |file| file.end_with?(".rbi") }
|
52
49
|
end
|
53
50
|
|
54
51
|
if files.empty?
|
55
|
-
say_error("No file matching `#{
|
52
|
+
say_error("No file matching `#{Sorbet::CONFIG_PATH}`")
|
56
53
|
exit(1)
|
57
54
|
end
|
58
55
|
|
59
56
|
if options[:tree]
|
60
|
-
tree = FileTree.new(files, strip_prefix:
|
57
|
+
tree = FileTree.new(files, strip_prefix: exec_path)
|
61
58
|
tree.print(colors: options[:color], indent_level: 0)
|
62
59
|
else
|
63
60
|
puts files
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Spoom
|
5
|
+
class Context
|
6
|
+
# Bundle features for a context
|
7
|
+
module Bundle
|
8
|
+
extend T::Sig
|
9
|
+
extend T::Helpers
|
10
|
+
|
11
|
+
requires_ancestor { Context }
|
12
|
+
|
13
|
+
# Read the `contents` of the Gemfile in this context directory
|
14
|
+
sig { returns(T.nilable(String)) }
|
15
|
+
def read_gemfile
|
16
|
+
read("Gemfile")
|
17
|
+
end
|
18
|
+
|
19
|
+
# Set the `contents` of the Gemfile in this context directory
|
20
|
+
sig { params(contents: String, append: T::Boolean).void }
|
21
|
+
def write_gemfile!(contents, append: false)
|
22
|
+
write!("Gemfile", contents, append: append)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Run a command with `bundle` in this context directory
|
26
|
+
sig { params(command: String, version: T.nilable(String), capture_err: T::Boolean).returns(ExecResult) }
|
27
|
+
def bundle(command, version: nil, capture_err: true)
|
28
|
+
command = "_#{version}_ #{command}" if version
|
29
|
+
exec("bundle #{command}", capture_err: capture_err)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Run `bundle install` in this context directory
|
33
|
+
sig { params(version: T.nilable(String), capture_err: T::Boolean).returns(ExecResult) }
|
34
|
+
def bundle_install!(version: nil, capture_err: true)
|
35
|
+
bundle("install", version: version, capture_err: capture_err)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Run a command `bundle exec` in this context directory
|
39
|
+
sig { params(command: String, version: T.nilable(String), capture_err: T::Boolean).returns(ExecResult) }
|
40
|
+
def bundle_exec(command, version: nil, capture_err: true)
|
41
|
+
bundle("exec #{command}", version: version, capture_err: capture_err)
|
42
|
+
end
|
43
|
+
|
44
|
+
# Get `gem` version from the `Gemfile.lock` content
|
45
|
+
#
|
46
|
+
# Returns `nil` if `gem` cannot be found in the Gemfile.
|
47
|
+
sig { params(gem: String).returns(T.nilable(String)) }
|
48
|
+
def gem_version_from_gemfile_lock(gem)
|
49
|
+
return nil unless file?("Gemfile.lock")
|
50
|
+
|
51
|
+
content = read("Gemfile.lock").match(/^ #{gem} \(.*(\d+\.\d+\.\d+).*\)/)
|
52
|
+
return nil unless content
|
53
|
+
|
54
|
+
content[1]
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Spoom
|
5
|
+
class ExecResult < T::Struct
|
6
|
+
extend T::Sig
|
7
|
+
|
8
|
+
const :out, String
|
9
|
+
const :err, T.nilable(String)
|
10
|
+
const :status, T::Boolean
|
11
|
+
const :exit_code, Integer
|
12
|
+
|
13
|
+
sig { returns(String) }
|
14
|
+
def to_s
|
15
|
+
<<~STR
|
16
|
+
########## STDOUT ##########
|
17
|
+
#{out.empty? ? "<empty>" : out}
|
18
|
+
########## STDERR ##########
|
19
|
+
#{err&.empty? ? "<empty>" : err}
|
20
|
+
########## STATUS: #{status} ##########
|
21
|
+
STR
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
class Context
|
26
|
+
# Execution features for a context
|
27
|
+
module Exec
|
28
|
+
extend T::Sig
|
29
|
+
extend T::Helpers
|
30
|
+
|
31
|
+
requires_ancestor { Context }
|
32
|
+
|
33
|
+
# Run a command in this context directory
|
34
|
+
sig { params(command: String, capture_err: T::Boolean).returns(ExecResult) }
|
35
|
+
def exec(command, capture_err: true)
|
36
|
+
Bundler.with_unbundled_env do
|
37
|
+
opts = T.let({ chdir: absolute_path }, T::Hash[Symbol, T.untyped])
|
38
|
+
|
39
|
+
if capture_err
|
40
|
+
out, err, status = Open3.capture3(command, opts)
|
41
|
+
ExecResult.new(out: out, err: err, status: T.must(status.success?), exit_code: T.must(status.exitstatus))
|
42
|
+
else
|
43
|
+
out, status = Open3.capture2(command, opts)
|
44
|
+
ExecResult.new(out: out, err: nil, status: T.must(status.success?), exit_code: T.must(status.exitstatus))
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Spoom
|
5
|
+
class Context
|
6
|
+
# File System features for a context
|
7
|
+
module FileSystem
|
8
|
+
extend T::Sig
|
9
|
+
extend T::Helpers
|
10
|
+
|
11
|
+
requires_ancestor { Context }
|
12
|
+
|
13
|
+
# Returns the absolute path to `relative_path` in the context's directory
|
14
|
+
sig { params(relative_path: String).returns(String) }
|
15
|
+
def absolute_path_to(relative_path)
|
16
|
+
File.join(absolute_path, relative_path)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Does the context directory at `absolute_path` exist and is a directory?
|
20
|
+
sig { returns(T::Boolean) }
|
21
|
+
def exist?
|
22
|
+
File.directory?(absolute_path)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Create the context directory at `absolute_path`
|
26
|
+
sig { void }
|
27
|
+
def mkdir!
|
28
|
+
FileUtils.rm_rf(absolute_path)
|
29
|
+
FileUtils.mkdir_p(absolute_path)
|
30
|
+
end
|
31
|
+
|
32
|
+
# List all files in this context matching `pattern`
|
33
|
+
sig { params(pattern: String).returns(T::Array[String]) }
|
34
|
+
def glob(pattern = "**/*")
|
35
|
+
Dir.glob(absolute_path_to(pattern)).map do |path|
|
36
|
+
Pathname.new(path).relative_path_from(absolute_path).to_s
|
37
|
+
end.sort
|
38
|
+
end
|
39
|
+
|
40
|
+
# List all files at the top level of this context directory
|
41
|
+
sig { returns(T::Array[String]) }
|
42
|
+
def list
|
43
|
+
glob("*")
|
44
|
+
end
|
45
|
+
|
46
|
+
# Does `relative_path` point to an existing file in this context directory?
|
47
|
+
sig { params(relative_path: String).returns(T::Boolean) }
|
48
|
+
def file?(relative_path)
|
49
|
+
File.file?(absolute_path_to(relative_path))
|
50
|
+
end
|
51
|
+
|
52
|
+
# Return the contents of the file at `relative_path` in this context directory
|
53
|
+
#
|
54
|
+
# Will raise if the file doesn't exist.
|
55
|
+
sig { params(relative_path: String).returns(String) }
|
56
|
+
def read(relative_path)
|
57
|
+
File.read(absolute_path_to(relative_path))
|
58
|
+
end
|
59
|
+
|
60
|
+
# Write `contents` in the file at `relative_path` in this context directory
|
61
|
+
#
|
62
|
+
# Append to the file if `append` is true.
|
63
|
+
sig { params(relative_path: String, contents: String, append: T::Boolean).void }
|
64
|
+
def write!(relative_path, contents = "", append: false)
|
65
|
+
absolute_path = absolute_path_to(relative_path)
|
66
|
+
FileUtils.mkdir_p(File.dirname(absolute_path))
|
67
|
+
File.write(absolute_path, contents, mode: append ? "a" : "w")
|
68
|
+
end
|
69
|
+
|
70
|
+
# Remove the path at `relative_path` (recursive + force) in this context directory
|
71
|
+
sig { params(relative_path: String).void }
|
72
|
+
def remove!(relative_path)
|
73
|
+
FileUtils.rm_rf(absolute_path_to(relative_path))
|
74
|
+
end
|
75
|
+
|
76
|
+
# Move the file or directory from `from_relative_path` to `to_relative_path`
|
77
|
+
sig { params(from_relative_path: String, to_relative_path: String).void }
|
78
|
+
def move!(from_relative_path, to_relative_path)
|
79
|
+
destination_path = absolute_path_to(to_relative_path)
|
80
|
+
FileUtils.mkdir_p(File.dirname(destination_path))
|
81
|
+
FileUtils.mv(absolute_path_to(from_relative_path), destination_path)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Delete this context and its content
|
85
|
+
#
|
86
|
+
# Warning: it will `rm -rf` the context directory on the file system.
|
87
|
+
sig { void }
|
88
|
+
def destroy!
|
89
|
+
FileUtils.rm_rf(absolute_path)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Spoom
|
5
|
+
module Git
|
6
|
+
class Commit < T::Struct
|
7
|
+
extend T::Sig
|
8
|
+
|
9
|
+
class << self
|
10
|
+
extend T::Sig
|
11
|
+
|
12
|
+
# Parse a line formated as `%h %at` into a `Commit`
|
13
|
+
sig { params(string: String).returns(T.nilable(Commit)) }
|
14
|
+
def parse_line(string)
|
15
|
+
sha, epoch = string.split(" ", 2)
|
16
|
+
return nil unless sha && epoch
|
17
|
+
|
18
|
+
time = Time.strptime(epoch, "%s")
|
19
|
+
Commit.new(sha: sha, time: time)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
const :sha, String
|
24
|
+
const :time, Time
|
25
|
+
|
26
|
+
sig { returns(Integer) }
|
27
|
+
def timestamp
|
28
|
+
time.to_i
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class Context
|
34
|
+
# Git features for a context
|
35
|
+
module Git
|
36
|
+
extend T::Sig
|
37
|
+
extend T::Helpers
|
38
|
+
|
39
|
+
requires_ancestor { Context }
|
40
|
+
|
41
|
+
# Run a command prefixed by `git` in this context directory
|
42
|
+
sig { params(command: String).returns(ExecResult) }
|
43
|
+
def git(command)
|
44
|
+
exec("git #{command}")
|
45
|
+
end
|
46
|
+
|
47
|
+
# Run `git init` in this context directory
|
48
|
+
#
|
49
|
+
# Warning: passing a branch will run `git init -b <branch>` which is only available in git 2.28+.
|
50
|
+
# In older versions, use `git_init!` followed by `git("checkout -b <branch>")`.
|
51
|
+
sig { params(branch: T.nilable(String)).returns(ExecResult) }
|
52
|
+
def git_init!(branch: nil)
|
53
|
+
if branch
|
54
|
+
git("init -b #{branch}")
|
55
|
+
else
|
56
|
+
git("init")
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Run `git checkout` in this context directory
|
61
|
+
sig { params(ref: String).returns(ExecResult) }
|
62
|
+
def git_checkout!(ref: "main")
|
63
|
+
git("checkout #{ref}")
|
64
|
+
end
|
65
|
+
|
66
|
+
# Run `git add . && git commit` in this context directory
|
67
|
+
sig { params(message: String, time: Time, allow_empty: T::Boolean).void }
|
68
|
+
def git_commit!(message: "message", time: Time.now.utc, allow_empty: false)
|
69
|
+
git("add --all")
|
70
|
+
|
71
|
+
args = ["-m '#{message}'", "--date '#{time}'"]
|
72
|
+
args << "--allow-empty" if allow_empty
|
73
|
+
|
74
|
+
exec("GIT_COMMITTER_DATE=\"#{time}\" git -c commit.gpgsign=false commit #{args.join(" ")}")
|
75
|
+
end
|
76
|
+
|
77
|
+
# Get the current git branch in this context directory
|
78
|
+
sig { returns(T.nilable(String)) }
|
79
|
+
def git_current_branch
|
80
|
+
res = git("branch --show-current")
|
81
|
+
return nil unless res.status
|
82
|
+
|
83
|
+
res.out.strip
|
84
|
+
end
|
85
|
+
|
86
|
+
# Run `git diff` in this context directory
|
87
|
+
sig { params(arg: String).returns(ExecResult) }
|
88
|
+
def git_diff(*arg)
|
89
|
+
git("diff #{arg.join(" ")}")
|
90
|
+
end
|
91
|
+
|
92
|
+
# Get the last commit in the currently checked out branch
|
93
|
+
sig { params(short_sha: T::Boolean).returns(T.nilable(Spoom::Git::Commit)) }
|
94
|
+
def git_last_commit(short_sha: true)
|
95
|
+
res = git_log("HEAD --format='%#{short_sha ? "h" : "H"} %at' -1")
|
96
|
+
return nil unless res.status
|
97
|
+
|
98
|
+
out = res.out.strip
|
99
|
+
return nil if out.empty?
|
100
|
+
|
101
|
+
Spoom::Git::Commit.parse_line(out)
|
102
|
+
end
|
103
|
+
|
104
|
+
sig { params(arg: String).returns(ExecResult) }
|
105
|
+
def git_log(*arg)
|
106
|
+
git("log #{arg.join(" ")}")
|
107
|
+
end
|
108
|
+
|
109
|
+
sig { params(arg: String).returns(ExecResult) }
|
110
|
+
def git_show(*arg)
|
111
|
+
git("show #{arg.join(" ")}")
|
112
|
+
end
|
113
|
+
|
114
|
+
# Is there uncommited changes in this context directory?
|
115
|
+
sig { params(path: String).returns(T::Boolean) }
|
116
|
+
def git_workdir_clean?(path: ".")
|
117
|
+
git_diff("HEAD").out.empty?
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|