lib/braid/operations.rb in braid-1.1.8 vs lib/braid/operations.rb in braid-1.1.9
- old
+ new
@@ -1,182 +1,251 @@
-# typed: true
+# typed: strict
require 'singleton'
require 'rubygems'
+require 'shellwords'
require 'tempfile'
module Braid
require 'open3'
module Operations
class ShellExecutionError < BraidError
+ # TODO (typing): Should this be nilable?
+ sig {returns(T.nilable(String))}
attr_reader :err, :out
+ sig {params(err: T.nilable(String), out: T.nilable(String)).void}
def initialize(err = nil, out = nil)
@err = err
@out = out
end
+ sig {returns(String)}
def message
- @err.to_s.split("\n").first
+ first_line = @err.to_s.split("\n").first
+ # Currently, first_line can be nil if @err was empty, but Sorbet thinks
+ # that the `message` method of an Exception should always return non-nil
+ # (although override checking isn't enforced as of this writing), so
+ # handle nil here. This seems ad-hoc but better than putting in a
+ # `T.must` that we know has a risk of being wrong. Hopefully this will
+ # be fixed better in https://github.com/cristibalan/braid/issues/90.
+ first_line.nil? ? '' : first_line
end
end
class VersionTooLow < BraidError
+ sig {params(command: String, version: String, required: String).void}
def initialize(command, version, required)
@command = command
- @version = version.to_s.split("\n").first
+ # TODO (typing): Probably should not be nilable
+ @version = T.let(version.to_s.split("\n").first, T.nilable(String))
@required = required
end
+ sig {returns(String)}
def message
"#{@command} version too low: #{@version}. #{@required} needed."
end
end
class UnknownRevision < BraidError
+ sig {returns(String)}
def message
"unknown revision: #{super}"
end
end
class LocalChangesPresent < BraidError
+ sig {returns(String)}
def message
'local changes are present'
end
end
class MergeError < BraidError
+ sig {returns(String)}
attr_reader :conflicts_text
+ sig {params(conflicts_text: String).void}
def initialize(conflicts_text)
@conflicts_text = conflicts_text
end
+ sig {returns(String)}
def message
'could not merge'
end
end
# The command proxy is meant to encapsulate commands such as git, that work with subcommands.
class Proxy
+ extend T::Sig
include Singleton
- def self.command;
- T.unsafe(name).split('::').last.downcase;
+ # TODO (typing): We could make this method abstract if our fake Sorbet
+ # runtime supported abstract methods.
+ sig {returns(String)}
+ def self.command
+ raise InternalError, 'Proxy.command not overridden'
end
# hax!
+ sig {returns(String)}
def version
- _, out, _ = exec!("#{self.class.command} --version")
+ _, out, _ = exec!([self.class.command, '--version'])
out.sub(/^.* version/, '').strip.sub(/ .*$/, '').strip
end
+ sig {params(required: String).returns(T::Boolean)}
def require_version(required)
# Gem::Version is intended for Ruby gem versions, but various web sites
# suggest it as a convenient way of comparing version strings in
# general. None of the fine points of its semantics compared to those
# of Git version numbers seem likely to cause a problem for Braid.
Gem::Version.new(version) >= Gem::Version.new(required)
end
+ sig {params(required: String).void}
def require_version!(required)
require_version(required) || raise(VersionTooLow.new(self.class.command, version, required))
end
private
+ sig {params(name: String).returns(T::Array[String])}
def command(name)
# stub
- name
+ [name]
end
- def invoke(arg, *args)
- exec!("#{command(arg)} #{args.join(' ')}".strip)[1].strip # return stdout
+ sig {params(arg: String, args: T::Array[String]).returns(String)}
+ def invoke(arg, args)
+ exec!(command(arg) + args)[1].strip # return stdout
end
- def method_missing(name, *args)
- # We have to use this rather than `T.unsafe` because `invoke` is
- # private. See https://sorbet.org/docs/type-assertions#tbind.
- T.bind(self, T.untyped)
- invoke(name, *args)
- end
+ # Some of the unit tests want to mock out `exec`, but they have no way to
+ # construct a real Process::Status and thus use an integer instead. We
+ # have to accommodate this in the type annotation to avoid runtime type
+ # check failures during the tests. In normal use of Braid, this will
+ # always be a real Process::Status. Fortunately, allowing Integer doesn't
+ # seem to cause any other problems right now.
+ ProcessStatusOrInteger = T.type_alias { T.any(Process::Status, Integer) }
+ sig {params(cmd: T::Array[String]).returns([ProcessStatusOrInteger, String, String])}
def exec(cmd)
- cmd.strip!
-
Operations::with_modified_environment({'LANG' => 'C'}) do
log(cmd)
- out, err, status = Open3.capture3(cmd)
+ # The special `[cmd[0], cmd[0]]` syntax ensures that `cmd[0]` is
+ # interpreted as the path of the executable and not a shell command
+ # even if `cmd` has only one element. See the documentation:
+ # https://ruby-doc.org/core-3.1.2/Process.html#method-c-spawn.
+ # Granted, this shouldn't matter for Braid for two reasons: (1)
+ # `cmd[0]` is always "git", which doesn't contain any shell special
+ # characters, and (2) `cmd` always has at least one additional
+ # argument (the Git subcommand). However, it's still nice to make our
+ # intent clear.
+ out, err, status = T.unsafe(Open3).capture3([cmd[0], cmd[0]], *cmd[1..])
[status, out, err]
end
end
+ sig {params(cmd: T::Array[String]).returns([ProcessStatusOrInteger, String, String])}
def exec!(cmd)
status, out, err = exec(cmd)
raise ShellExecutionError.new(err, out) unless status == 0
[status, out, err]
end
+ sig {params(cmd: T::Array[String]).returns(ProcessStatusOrInteger)}
def system(cmd)
- cmd.strip!
-
# Without this, "braid diff" output came out in the wrong order on Windows.
$stdout.flush
$stderr.flush
Operations::with_modified_environment({'LANG' => 'C'}) do
- Kernel.system(cmd)
+ # See the comment in `exec` about the `[cmd[0], cmd[0]]` syntax.
+ T.unsafe(Kernel).system([cmd[0], cmd[0]], *cmd[1..])
return $?
end
end
+ sig {params(str: String).void}
def msg(str)
puts "Braid: #{str}"
end
+ sig {params(cmd: T::Array[String]).void}
def log(cmd)
- msg "Executing `#{cmd}` in #{Dir.pwd}" if verbose?
+ # Note: `Shellwords.shelljoin` follows Bourne shell quoting rules, as
+ # its documentation states. This may not be what a Windows user
+ # expects, but it's not worth the trouble to try to find a library that
+ # produces something better on Windows, especially because it's unclear
+ # which of Windows's several different quoted formats we would use
+ # (e.g., CommandLineToArgvW, cmd.exe, or PowerShell). The most
+ # important thing is to use _some_ unambiguous representation.
+ msg "Executing `#{Shellwords.shelljoin(cmd)}` in #{Dir.pwd}" if verbose?
end
+ sig {returns(T::Boolean)}
def verbose?
Braid.verbose
end
end
class Git < Proxy
+
+ sig {returns(String)}
+ def self.command
+ 'git'
+ end
+
+ # A string representing a Git object ID (i.e., hash). This type alias is
+ # used as documentation and is not enforced, so there's a risk that we
+ # mistakenly mark something as an ObjectID when it can actually be a
+ # String that is not an ObjectID.
+ ObjectID = T.type_alias { String }
+
+ # A string containing an expression that can be evaluated to an object ID
+ # by `git rev-parse`. Ditto the remark about lack of enforcement.
+ ObjectExpr = T.type_alias { String }
+
# Get the physical path to a file in the git repository (e.g.,
# 'MERGE_MSG'), taking into account worktree configuration. The returned
# path may be absolute or relative to the current working directory.
+ sig {params(path: String).returns(String)}
def repo_file_path(path)
- invoke(:rev_parse, '--git-path', path)
+ invoke('rev-parse', ['--git-path', path])
end
# If the current directory is not inside a git repository at all, this
# command will fail with "fatal: Not a git repository" and that will be
# propagated as a ShellExecutionError. is_inside_worktree can return
# false when inside a bare repository and in certain other rare cases such
# as when the GIT_WORK_TREE environment variable is set.
+ sig {returns(T::Boolean)}
def is_inside_worktree
- invoke(:rev_parse, '--is-inside-work-tree') == 'true'
+ invoke('rev-parse', ['--is-inside-work-tree']) == 'true'
end
# Get the prefix of the current directory relative to the worktree. Empty
# string if it's the root of the worktree, otherwise ends with a slash.
# In some cases in which the current directory is not inside a worktree at
# all, this will successfully return an empty string, so it may be
# desirable to check is_inside_worktree first.
+ sig {returns(String)}
def relative_working_dir
- invoke(:rev_parse, '--show-prefix')
+ invoke('rev-parse', ['--show-prefix'])
end
- def commit(message, *args)
- cmd = 'git commit --no-verify'
+ sig {params(message: T.nilable(String), args: T::Array[String]).returns(T::Boolean)}
+ def commit(message, args = [])
+ cmd = ['git', 'commit', '--no-verify']
message_file = nil
if message # allow nil
message_file = Tempfile.new('braid_commit')
message_file.print("Braid: #{message}")
message_file.flush
message_file.close
- cmd << " -F #{message_file.path}"
+ cmd += ['-F', T.must(message_file.path)]
end
- cmd << " #{args.join(' ')}" unless args.empty?
+ cmd += args
status, out, err = exec(cmd)
message_file.unlink if message_file
if status == 0
true
@@ -185,54 +254,59 @@
else
raise ShellExecutionError, err
end
end
- def fetch(remote = nil, *args)
- args.unshift "-n #{remote}" if remote
- exec!("git fetch #{args.join(' ')}")
+ sig {params(remote: T.nilable(String), args: T::Array[String]).void}
+ def fetch(remote = nil, args = [])
+ args = ['-n', remote] + args if remote
+ exec!(['git', 'fetch'] + args)
end
- def checkout(treeish)
- invoke(:checkout, treeish)
- true
- end
-
# Returns the base commit or nil.
+ sig {params(target: ObjectExpr, source: ObjectExpr).returns(T.nilable(ObjectID))}
def merge_base(target, source)
- invoke(:merge_base, target, source)
+ invoke('merge-base', [target, source])
rescue ShellExecutionError
nil
end
- def rev_parse(opt)
- invoke(:rev_parse, opt)
+ sig {params(expr: ObjectExpr).returns(ObjectID)}
+ def rev_parse(expr)
+ invoke('rev-parse', [expr])
rescue ShellExecutionError
- raise UnknownRevision, opt
+ raise UnknownRevision, expr
end
# Implies tracking.
+ #
+ # TODO (typing): Remove the return value if we're confident that nothing
+ # uses it, here and in similar cases.
+ sig {params(remote: String, path: String).returns(TrueClass)}
def remote_add(remote, path)
- invoke(:remote, 'add', remote, path)
+ invoke('remote', ['add', remote, path])
true
end
+ sig {params(remote: String).returns(TrueClass)}
def remote_rm(remote)
- invoke(:remote, 'rm', remote)
+ invoke('remote', ['rm', remote])
true
end
# Checks git remotes.
+ sig {params(remote: String).returns(T.nilable(String))}
def remote_url(remote)
key = "remote.#{remote}.url"
- invoke(:config, key)
+ invoke('config', [key])
rescue ShellExecutionError
nil
end
+ sig {params(target: ObjectExpr).returns(TrueClass)}
def reset_hard(target)
- invoke(:reset, '--hard', target)
+ invoke('reset', ['--hard', target])
true
end
# Merge three trees (local_treeish should match the current state of the
# index) and update the index and working tree.
@@ -240,45 +314,63 @@
# The usage of 'git merge-recursive' doesn't seem to be officially
# documented, but it does accept trees. When a single base is passed, the
# 'recursive' part (i.e., merge of bases) does not come into play and only
# the trees matter. But for some reason, Git's smartest tree merge
# algorithm is only available via the 'recursive' strategy.
+ sig {params(base_treeish: ObjectExpr, local_treeish: ObjectExpr, remote_treeish: ObjectExpr).returns(TrueClass)}
def merge_trees(base_treeish, local_treeish, remote_treeish)
- invoke(:merge_recursive, base_treeish, "-- #{local_treeish} #{remote_treeish}")
+ invoke('merge-recursive', [base_treeish, '--', local_treeish, remote_treeish])
true
rescue ShellExecutionError => error
# 'CONFLICT' messages go to stdout.
raise MergeError, error.out
end
+ sig {params(prefix: String).returns(String)}
def read_ls_files(prefix)
- invoke('ls-files', prefix)
+ invoke('ls-files', [prefix])
end
class BlobWithMode
+ extend T::Sig
+ sig {params(hash: ObjectID, mode: String).void}
def initialize(hash, mode)
@hash = hash
@mode = mode
end
- attr_reader :hash, :mode
+ sig {returns(ObjectID)}
+ attr_reader :hash
+ sig {returns(String)}
+ attr_reader :mode
end
# Allow the class to be referenced as `git.BlobWithMode`.
+ sig {returns(T.class_of(BlobWithMode))}
def BlobWithMode
Git::BlobWithMode
end
+ # An ObjectID used as a TreeItem represents a tree.
+ TreeItem = T.type_alias { T.any(ObjectID, BlobWithMode) }
# Get the item at the given path in the given tree. If it's a tree, just
# return its hash; if it's a blob, return a BlobWithMode object. (This is
# how we remember the mode for single-file mirrors.)
+ # TODO (typing): Should `path` be nilable?
+ sig {params(tree: ObjectExpr, path: T.nilable(String)).returns(TreeItem)}
def get_tree_item(tree, path)
if path.nil? || path == ''
tree
else
- m = T.must(/^([^ ]*) ([^ ]*) ([^\t]*)\t.*$/.match(invoke(:ls_tree, tree, path)))
- mode = m[1]
- type = m[2]
- hash = m[3]
+ m = /^([^ ]*) ([^ ]*) ([^\t]*)\t.*$/.match(invoke('ls-tree', [tree, path]))
+ if m.nil?
+ # This can happen if the user runs `braid add` with a `--path` that
+ # doesn't exist. TODO: Make the error message more user-friendly in
+ # that case.
+ raise ShellExecutionError, 'No tree item exists at the given path'
+ end
+ mode = T.must(m[1])
+ type = T.must(m[2])
+ hash = T.must(m[3])
if type == 'tree'
hash
elsif type == 'blob'
return BlobWithMode.new(hash, mode)
else
@@ -289,47 +381,60 @@
# Add the item (as returned by get_tree_item) to the index at the given
# path. If update_worktree is true, then update the worktree, otherwise
# disregard the state of the worktree (most useful with a temporary index
# file).
+ sig {params(item: TreeItem, path: String, update_worktree: T::Boolean).void}
def add_item_to_index(item, path, update_worktree)
if item.is_a?(BlobWithMode)
- invoke(:update_index, '--add', '--cacheinfo', "#{item.mode},#{item.hash},#{path}")
+ invoke('update-index', ['--add', '--cacheinfo', "#{item.mode},#{item.hash},#{path}"])
if update_worktree
# XXX If this fails, we've already updated the index.
- invoke(:checkout_index, path)
+ invoke('checkout-index', [path])
end
else
# According to
# https://lore.kernel.org/git/e48a281a4d3db0a04c0609fcb8658e4fcc797210.1646166271.git.gitgitgadget@gmail.com/,
# `--prefix=` is valid if the path is empty.
- invoke(:read_tree, "--prefix=#{path}", update_worktree ? '-u' : '-i', item)
+ invoke('read-tree', ["--prefix=#{path}", update_worktree ? '-u' : '-i', item])
end
end
# Read tree into the root of the index. This may not be the preferred way
# to do it, but it seems to work.
+ sig {params(treeish: ObjectExpr).void}
def read_tree_im(treeish)
- invoke(:read_tree, '-im', treeish)
- true
+ invoke('read-tree', ['-im', treeish])
end
+ sig {params(treeish: ObjectExpr).void}
+ def read_tree_um(treeish)
+ invoke('read-tree', ['-um', treeish])
+ end
+
# Write a tree object for the current index and return its ID.
+ sig {returns(ObjectID)}
def write_tree
- invoke(:write_tree)
+ invoke('write-tree', [])
end
# Execute a block using a temporary git index file, initially empty.
- def with_temporary_index
+ sig {
+ type_parameters(:R).params(
+ blk: T.proc.returns(T.type_parameter(:R))
+ ).returns(T.type_parameter(:R))
+ }
+ def with_temporary_index(&blk)
Dir.mktmpdir('braid_index') do |dir|
Operations::with_modified_environment(
{'GIT_INDEX_FILE' => File.join(dir, 'index')}) do
yield
end
end
end
+ sig {params(main_content: T.nilable(ObjectExpr), item_path: String, item: TreeItem).returns(ObjectID)}
def make_tree_with_item(main_content, item_path, item)
with_temporary_index do
# If item_path is '', then rm_r_cached will fail. But in that case,
# we can skip loading the main content because it would be deleted
# anyway.
@@ -340,70 +445,121 @@
add_item_to_index(item, item_path, false)
write_tree
end
end
+ sig {params(args: T::Array[String]).returns(T.nilable(String))}
def config(args)
- invoke(:config, args) rescue nil
+ invoke('config', args) rescue nil
end
+ sig {params(path: String).void}
+ def add(path)
+ invoke('add', [path])
+ end
+
+ sig {params(path: String).void}
+ def rm(path)
+ invoke('rm', [path])
+ end
+
+ sig {params(path: String).returns(TrueClass)}
def rm_r(path)
- invoke(:rm, '-r', path)
+ invoke('rm', ['-r', path])
true
end
# Remove from index only.
+ sig {params(path: String).returns(TrueClass)}
def rm_r_cached(path)
- invoke(:rm, '-r', '--cached', path)
+ invoke('rm', ['-r', '--cached', path])
true
end
+ sig {params(path: String, treeish: ObjectExpr).returns(ObjectID)}
def tree_hash(path, treeish = 'HEAD')
- out = invoke(:ls_tree, treeish, '-d', path)
- out.split[2]
+ out = invoke('ls-tree', [treeish, '-d', path])
+ T.must(out.split[2])
end
- def diff_to_stdout(*args)
+ sig {params(args: T::Array[String]).returns(String)}
+ def diff(args)
+ invoke('diff', args)
+ end
+
+ sig {params(args: T::Array[String]).returns(ProcessStatusOrInteger)}
+ def diff_to_stdout(args)
# For now, ignore the exit code. It can be 141 (SIGPIPE) if the user
# quits the pager before reading all the output.
- system("git diff #{args.join(' ')}")
+ system(['git', 'diff'] + args)
end
+ sig {returns(T::Boolean)}
def status_clean?
- _, out, _ = exec('git status')
+ _, out, _ = exec(['git', 'status'])
!out.split("\n").grep(/nothing to commit/).empty?
end
+ sig {void}
def ensure_clean!
status_clean? || raise(LocalChangesPresent)
end
+ sig {returns(ObjectID)}
def head
rev_parse('HEAD')
end
- def branch
- _, out, _ = exec!("git branch | grep '*'")
- out[2..-1]
+ sig {void}
+ def init
+ invoke('init', [])
end
- def clone(*args)
- # overrides builtin
- T.bind(self, T.untyped) # Ditto the comment in `method_missing`.
- invoke(:clone, *args)
+ sig {params(args: T::Array[String]).void}
+ def clone(args)
+ invoke('clone', args)
end
+ # Wrappers for Git commands that were called via `method_missing` before
+ # the move to static typing but for which the existing calls don't follow
+ # a clear enough pattern around which we could design a narrower API than
+ # forwarding an arbitrary argument list. We may narrow the API in the
+ # future if it becomes clear what it should be.
+
+ sig {params(args: T::Array[String]).returns(String)}
+ def rev_list(args)
+ invoke('rev-list', args)
+ end
+
+ sig {params(args: T::Array[String]).void}
+ def update_ref(args)
+ invoke('update-ref', args)
+ end
+
+ sig {params(args: T::Array[String]).void}
+ def push(args)
+ invoke('push', args)
+ end
+
+ sig {params(args: T::Array[String]).returns(String)}
+ def ls_remote(args)
+ invoke('ls-remote', args)
+ end
+
private
+ sig {params(name: String).returns(T::Array[String])}
def command(name)
- "#{self.class.command} #{name.to_s.gsub('_', '-')}"
+ [self.class.command, name]
end
end
class GitCache
+ extend T::Sig
include Singleton
+ sig {params(url: String).void}
def fetch(url)
dir = path(url)
# remove local cache if it was created with --no-checkout
if File.exist?("#{dir}/.git")
@@ -414,33 +570,39 @@
Dir.chdir(dir) do
git.fetch
end
else
FileUtils.mkdir_p(local_cache_dir)
- git.clone('--mirror', url, dir)
+ git.clone(['--mirror', url, dir])
end
end
+ sig {params(url: String).returns(String)}
def path(url)
File.join(local_cache_dir, url.gsub(/[\/:@]/, '_'))
end
private
+ sig {returns(String)}
def local_cache_dir
Braid.local_cache_dir
end
+ sig {returns(Git)}
def git
Git.instance
end
end
module VersionControl
+ extend T::Sig
+ sig {returns(Git)}
def git
Git.instance
end
+ sig {returns(GitCache)}
def git_cache
GitCache.instance
end
end
end