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