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 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