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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c41426fe9cc0e4f20115b5ce81ff2256d00f255acf59f29beb7e6ffeef16b0c
4
- data.tar.gz: ea751643663fc13175ad1ec23c39872a7b963b2f6f619a497e4bc6fc51052e8f
3
+ metadata.gz: 323ebe199d0efed57d5e92af12eb377c3c5af35e468c83db851b27f86b44117b
4
+ data.tar.gz: c0fb24e2af1af3c0e460bb7c06ba9f3cff9cda471ec829ba00742b40dd67ab84
5
5
  SHA512:
6
- metadata.gz: 4503195f8c603f0148f9393a2fdfdb5667ef6b6a9d43917484952409cfa69ce5edc86fda0275144451790d5e2928fe33b7fd19597ab0f9500678185e65f65b33
7
- data.tar.gz: 4eb49038a48389c0ac8160c1f9b7e37f80275c8d39fda35a8d08b1e75bae0e763382f24419a1fc51a3e2c7972d4e4d8e2ef22996961bb9a003ee7ad4bbb1abbb
6
+ metadata.gz: faa5f86d378dd4cf56aa6e680cdee5717c59069b464b823bebfc63e7c8a7a40aeb7a75d86ec4dd0c69e025c4ad397eff740fdfa2ac134f49868736197836790a
7
+ data.tar.gz: 67f7d4bc72282ff4bea575e85c2eed80f7195f94d3b518e3a84af57085bed90c757aedab5cfe33fb6d14dc96b62f21c6e8aa8cb56b79d4e9b148318a7c713e98
@@ -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
- in_sorbet_project!
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 = config_files(path: exec_path)
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
- Sorbet.srb_tc(
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
@@ -13,10 +13,11 @@ module Spoom
13
13
 
14
14
  desc "show", "Show Sorbet config"
15
15
  def show
16
- in_sorbet_project!
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 `#{sorbet_config_file}`.")
20
+ say("Found Sorbet config at `#{config_path}`.")
20
21
 
21
22
  say("\nPaths typechecked:")
22
23
  if config.paths.empty?
@@ -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
- in_sorbet_project!
22
- path = exec_path
21
+ context = context_requiring_sorbet!
23
22
  sorbet = options[:sorbet]
24
23
 
25
- snapshot = Spoom::Coverage.snapshot(path: path, rbi: options[:rbi], sorbet_bin: sorbet)
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
- in_sorbet_project!
43
+ context = context_requiring_sorbet!
45
44
  path = exec_path
46
45
  sorbet = options[:sorbet]
47
46
 
48
- ref_before = Spoom::Git.current_branch
49
- ref_before = Spoom::Git.last_commit(path: path)&.sha unless 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 Spoom::Git.workdir_clean?(path: path)
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 = Spoom::Git.sorbet_intro_commit(path: path)
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, path: path)
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
- Spoom::Git.checkout(commit.sha, path: path)
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(path: path, sorbet_bin: sorbet)
96
+ snapshot = Spoom::Coverage.snapshot(context, sorbet_bin: sorbet)
98
97
  end
99
98
  else
100
- snapshot = Spoom::Coverage.snapshot(path: path, sorbet_bin: sorbet)
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
- Spoom::Git.checkout(ref_before, path: path)
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
- in_sorbet_project!
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, path: exec_path)
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}`")
@@ -46,25 +46,25 @@ module Spoom
46
46
  $stderr.flush
47
47
  end
48
48
 
49
- # Is `spoom` ran inside a project with a `sorbet/config` file?
50
- sig { returns(T::Boolean) }
51
- def in_sorbet_project?
52
- File.file?(sorbet_config_file)
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
- # Enforce that `spoom` is ran inside a project with a `sorbet/config` file
56
- #
57
- # Display an error message and exit otherwise.
58
- sig { void }
59
- def in_sorbet_project!
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 (`#{sorbet_config_file}` not found)\n\n" \
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
- in_sorbet_project!
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
- in_sorbet_project!
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
- in_sorbet_project!
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(Spoom::Sorbet).srb_tc(
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(Spoom::Sorbet).srb_tc(
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
- in_sorbet_project!
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 `#{sorbet_config_file}`")
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: path)
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