braid 1.1.7 → 1.1.9

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: ad14f6dd7a5d428c5796bbd19039aae6e59f5803d93da3889fe0a92a8afd032b
4
- data.tar.gz: 9d6bcfd73c5a306f853e6b9653edda5aff6a7bbd3541b6ab644bd7b96bc510e6
3
+ metadata.gz: 0ddcc3421c5a2dffcf7634e71118d7392d9f506ab49064f25eb2c38f7f0d67b1
4
+ data.tar.gz: d3ad647f242d287ece52de41be7edb56cfdc3c469336ee84bb35c15257ca4d36
5
5
  SHA512:
6
- metadata.gz: 817cd08525921352f103a81f05b6c550e8eaff62fb647c04d0ecb746e6ed425a78ef1bea4a477786675c406b0e23982f36a52eea205278a3b25ccee6e64ecdf5
7
- data.tar.gz: 76d9e62b1d58a437a8eae77f879b5a66ec6a30cda270d267cc458e621a3f1560fa192f5cc435b5e718c9f093691464d458b049d2e236864d0faea6e801c89629
6
+ metadata.gz: f002d88fa8bb752ab878d746c8b1ead6c73b80518d37a06f261eba2b99540c6081c7991a9f404ec0ca26c39165b6e9345725e4b4f49f52ad132ca1e359465e39
7
+ data.tar.gz: 4da0947c03c1846b073b331f4118f8b07e42b4138c3450b3f2ce6ba967e3a8451edf843622e3e6b6bbe4f5c70951866d7c6abbcc7e1211d10501b75fefa8e095
@@ -1,3 +1,10 @@
1
+ # Since this file can't safely depend on the Sorbet runtime, it isn't prudent to
2
+ # try to commit to `typed: true` even if the file currently passes type checking
3
+ # without needing any references to `T`. Like `exe/braid`, this file doesn't
4
+ # have much code worth type checking.
5
+ #
6
+ # typed: false
7
+
1
8
  # Braid has several entry points that run code from Ruby gems (either Braid
2
9
  # itself or dependencies such as Sorbet) and expect to get the correct versions
3
10
  # (either the same copy of Braid or versions of dependencies consistent with the
data/lib/braid/command.rb CHANGED
@@ -17,6 +17,11 @@ module Braid
17
17
  klass.new.run(*args)
18
18
 
19
19
  rescue BraidError => error
20
+ handle_error(error)
21
+ end
22
+
23
+ sig {params(error: BraidError).returns(T.noreturn)}
24
+ def self.handle_error(error)
20
25
  case error
21
26
  when Operations::ShellExecutionError
22
27
  msg "Shell error: #{error.message}"
@@ -36,6 +41,7 @@ module Braid
36
41
  self.class.msg(str)
37
42
  end
38
43
 
44
+ sig {returns(Config)}
39
45
  def config
40
46
  @config ||= Config.new({'mode' => config_mode})
41
47
  end
@@ -1,15 +1,55 @@
1
+ # typed: true
1
2
  module Braid
2
3
  module Commands
3
4
  class Add < Command
5
+ # Returns the default branch name of the repository at the given URL, or
6
+ # nil if it couldn't be determined.
7
+ #
8
+ # We won't be able to determine a default branch in certain cases that we
9
+ # expect to be unusual in the context of Braid, such as if the HEAD is
10
+ # detached or points to a ref outside of `refs/heads`. (Presumably, the
11
+ # same thing happens if the server is too old to report symrefs to us.)
12
+ # In those cases, a plausible alternative behavior would be to just lock
13
+ # the mirror to the remote HEAD revision, but that's probably not what the
14
+ # user wants. It's much more likely that something is wrong and Braid
15
+ # should report an error.
16
+ def get_default_branch_name(url)
17
+ head_targets = []
18
+ # The `HEAD` parameter here doesn't appear to do an exact match (it
19
+ # appears to match any ref with `HEAD` as the last path component, such
20
+ # as `refs/remotes/origin/HEAD` in the unusual case where the repository
21
+ # contains its own remote-tracking branches), but it reduces the data we
22
+ # have to scan a bit.
23
+ git.ls_remote(['--symref', url, 'HEAD']).split("\n").each do |line|
24
+ m = /^ref: (.*)\tHEAD$/.match(line)
25
+ head_targets.push(m[1]) if m
26
+ end
27
+ return nil unless head_targets.size == 1
28
+ m = /^refs\/heads\/(.*)$/.match(head_targets[0])
29
+ return nil unless m
30
+ m[1]
31
+ end
32
+
4
33
  def run(url, options = {})
5
34
  with_reset_on_error do
35
+ if options['branch'].nil? && options['tag'].nil? && options['revision'].nil?
36
+ default_branch = get_default_branch_name(url)
37
+ if default_branch.nil?
38
+ raise BraidError, <<-MSG
39
+ Failed to detect the default branch of the remote repository. Please specify
40
+ the branch you want to use via the --branch option.
41
+ MSG
42
+ end
43
+ options['branch'] = default_branch
44
+ end
45
+
6
46
  mirror = config.add_from_options(url, options)
7
47
  add_config_file
8
48
 
9
49
  mirror.branch = nil if options['revision']
10
50
  raise BraidError, 'Can not add mirror specifying both a revision and a tag' if options['revision'] && mirror.tag
11
51
 
12
- branch_message = (mirror.branch.nil? || mirror.branch == 'master') ? '' : " branch '#{mirror.branch}'"
52
+ branch_message = mirror.branch.nil? ? '' : " branch '#{mirror.branch}'"
13
53
  tag_message = mirror.tag.nil? ? '' : " tag '#{mirror.tag}'"
14
54
  revision_message = options['revision'] ? " at #{display_revision(mirror, options['revision'])}" : ''
15
55
  msg "Adding mirror of '#{mirror.url}'#{branch_message}#{tag_message}#{revision_message}."
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  module Braid
2
3
  module Commands
3
4
  class Diff < Command
@@ -35,7 +36,7 @@ module Braid
35
36
 
36
37
  # XXX: Warn if the user specifies file paths that are outside the
37
38
  # mirror? Currently, they just won't match anything.
38
- git.diff_to_stdout(*mirror.diff_args(options['git_diff_args']))
39
+ git.diff_to_stdout(mirror.diff_args(options['git_diff_args']))
39
40
 
40
41
  clear_remote(mirror, options)
41
42
  end
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  require 'fileutils'
2
3
  require 'tmpdir'
3
4
 
@@ -43,7 +44,8 @@ module Braid
43
44
  end
44
45
  clone_dir = Dir.tmpdir + "/braid_push.#{$$}"
45
46
  Dir.mkdir(clone_dir)
46
- remote_url = git.remote_url(mirror.remote)
47
+ # TODO (typing): Remove this `T.must` somehow?
48
+ remote_url = T.must(git.remote_url(mirror.remote))
47
49
  if remote_url == mirror.cached_url
48
50
  remote_url = mirror.url
49
51
  elsif File.directory?(remote_url)
@@ -67,7 +69,7 @@ module Braid
67
69
  File.open('.git/objects/info/alternates', 'wb') { |f|
68
70
  f.puts(odb_paths)
69
71
  }
70
- git.fetch(remote_url, mirror.remote_ref)
72
+ git.fetch(remote_url, [mirror.remote_ref])
71
73
  new_tree = git.make_tree_with_item(base_revision,
72
74
  mirror.remote_path || '', local_mirror_item)
73
75
  if git.require_version('2.27')
@@ -105,11 +107,11 @@ module Braid
105
107
  # Update HEAD the same way git.checkout(base_revision) would, but
106
108
  # don't populate the index or working tree (to save us the trouble of
107
109
  # emptying them again before the git.read_tree).
108
- git.update_ref('--no-deref', 'HEAD', base_revision)
109
- git.read_tree('-mu', new_tree)
110
+ git.update_ref(['--no-deref', 'HEAD', base_revision])
111
+ git.read_tree_um(new_tree)
110
112
  system('git commit -v')
111
113
  msg "Pushing changes to remote branch #{branch}."
112
- git.push(remote_url, "HEAD:refs/heads/#{branch}")
114
+ git.push([remote_url, "HEAD:refs/heads/#{branch}"])
113
115
  end
114
116
  FileUtils.rm_r(clone_dir)
115
117
 
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  module Braid
2
3
  module Commands
3
4
  class Remove < Command
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  module Braid
2
3
  module Commands
3
4
  class Setup < Command
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  module Braid
2
3
  module Commands
3
4
  class Status < Command
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  module Braid
2
3
  module Commands
3
4
  class Update < Command
@@ -1,3 +1,4 @@
1
+ # typed: true
1
2
  module Braid
2
3
  module Commands
3
4
  class UpgradeConfig < Command
data/lib/braid/main.rb CHANGED
@@ -1,13 +1,24 @@
1
+ # typed: true
2
+
1
3
  require 'braid'
2
4
 
3
5
  require 'rubygems'
4
6
  require 'main'
5
7
 
8
+ # This is needed for `T` below to resolve to `Braid::T` when using the fake
9
+ # Sorbet runtime. TODO: Indent the contents and accept the large diff?
10
+ module Braid
11
+
6
12
  Home = File.expand_path(ENV['HOME'] || '~')
7
13
 
8
14
  # mostly blantantly stolen from ara's punch script
9
15
  # main kicks ass!
10
- Main {
16
+ T.unsafe(Main).run {
17
+ # `Main` is somewhat mind-bending and I'm unsure what the type of `self`
18
+ # actually is here, but whatever it is, we don't have a type declaration for
19
+ # it.
20
+ T.bind(self, T.untyped)
21
+
11
22
  description <<-TXT
12
23
  braid is a simple tool to help track git repositories inside a git repository.
13
24
 
@@ -20,7 +31,8 @@ Main {
20
31
  # The "main" library doesn't provide a way to do this??
21
32
  def check_no_extra_args!
22
33
  if @argv.length > 0
23
- die 'Extra argument(s) passed to command.'
34
+ Braid::Command.handle_error(
35
+ Braid::BraidError.new('Extra argument(s) passed to command.'))
24
36
  end
25
37
  end
26
38
 
@@ -123,7 +135,7 @@ Main {
123
135
 
124
136
  mixin :optional_local_path, :option_verbose, :option_keep_remote
125
137
 
126
- synopsis(Main::Usage.default_synopsis(self) + ' [-- git_diff_arg*]')
138
+ synopsis(T.unsafe(Main::Usage).default_synopsis(self) + ' [-- git_diff_arg*]')
127
139
 
128
140
  run {
129
141
  if @argv.length > 0 && @argv[0] == '--'
@@ -318,3 +330,5 @@ Main {
318
330
 
319
331
  run { help! }
320
332
  }
333
+
334
+ end
data/lib/braid/mirror.rb CHANGED
@@ -86,7 +86,7 @@ DESC
86
86
  raise NoTagAndBranch if options['tag'] && options['branch']
87
87
 
88
88
  tag = options['tag']
89
- branch = options['branch'] || (tag.nil? ? 'master' : nil)
89
+ branch = options['branch']
90
90
 
91
91
  path = (options['path'] || extract_path_from_url(url, options['remote_path'])).sub(/\/$/, '')
92
92
  raise PathRequired unless path
@@ -107,7 +107,7 @@ DESC
107
107
  branch.nil? && tag.nil?
108
108
  end
109
109
 
110
- sig {params(commit: String).returns(T::Boolean)}
110
+ sig {params(commit: Operations::Git::ObjectExpr).returns(T::Boolean)}
111
111
  def merged?(commit)
112
112
  # tip from spearce in #git:
113
113
  # `test z$(git merge-base A B) = z$(git rev-parse --verify A)`
@@ -115,9 +115,7 @@ DESC
115
115
  !!base_revision && git.merge_base(commit, base_revision) == commit
116
116
  end
117
117
 
118
- # We'll probably call the return type something like
119
- # Braid::Operations::Git::TreeItem.
120
- sig {params(revision: String).returns(T.untyped)}
118
+ sig {params(revision: String).returns(Operations::Git::TreeItem)}
121
119
  def upstream_item_for_revision(revision)
122
120
  git.get_tree_item(revision, self.remote_path)
123
121
  end
@@ -211,16 +209,28 @@ DESC
211
209
  git.remote_url(remote) == cached_url
212
210
  end
213
211
 
214
- sig {returns(String)}
212
+ sig {returns(Operations::Git::ObjectID)}
215
213
  def base_revision
216
- # Avoid a Sorbet "unreachable code" error.
217
- # TODO (typing): Is the revision expected to be non-nil nowadays? Can we
218
- # just remove the `inferred_revision` code path now?
219
- nilable_revision = T.let(revision, T.nilable(String))
220
- if nilable_revision
221
- git.rev_parse(revision)
214
+ # TODO (typing): We think `revision` should always be non-nil here these
215
+ # days and we can completely drop the `inferred_revision` code, but we're
216
+ # waiting for a better time to actually make this runtime behavior change
217
+ # and accept any risk of breakage
218
+ # (https://github.com/cristibalan/braid/pull/105/files#r857150464).
219
+ #
220
+ # Temporary variable
221
+ # (https://sorbet.org/docs/flow-sensitive#limitations-of-flow-sensitivity)
222
+ revision1 = revision
223
+ if revision1
224
+ git.rev_parse(revision1)
222
225
  else
223
- inferred_revision
226
+ # NOTE: Given that `inferred_revision` does appear to return nil on one
227
+ # code path, using this `T.must` and giving `base_revision` a
228
+ # non-nilable return type presents a theoretical risk of leading us to
229
+ # make changes to callers that break things at runtime. But we judge
230
+ # this a lesser evil than making the return type nilable and changing
231
+ # all callers to type-check successfully with that when we hope to
232
+ # revert the change soon anyway.
233
+ T.must(inferred_revision)
224
234
  end
225
235
  end
226
236
 
@@ -310,7 +320,12 @@ DESC
310
320
 
311
321
  sig {returns(String)}
312
322
  def remote
313
- "#{branch || tag || 'revision'}/braid/#{path}".gsub(/\/\./, '/_')
323
+ # Ensure that we replace any characters in the mirror path that might be
324
+ # problematic in a Git ref name. Theoretically, this may introduce
325
+ # collisions between mirrors, but we don't expect that to be much of a
326
+ # problem because Braid doesn't keep remotes by default after a command
327
+ # exits.
328
+ "#{branch || tag || 'revision'}_braid_#{path}".gsub(/[^-A-Za-z0-9]/, '_')
314
329
  end
315
330
 
316
331
  private
@@ -322,8 +337,8 @@ DESC
322
337
 
323
338
  sig {returns(T.nilable(String))}
324
339
  def inferred_revision
325
- local_commits = git.rev_list('HEAD', "-- #{path}").split("\n")
326
- remote_hashes = git.rev_list("--pretty=format:\"%T\"", remote).split('commit ').map do |chunk|
340
+ local_commits = git.rev_list(['HEAD', "-- #{path}"]).split("\n")
341
+ remote_hashes = git.rev_list(["--pretty=format:%T", remote]).split('commit ').map do |chunk|
327
342
  chunk.split("\n", 2).map { |value| value.strip }
328
343
  end
329
344
  hash = T.let(nil, T.nilable(String))
@@ -1,5 +1,8 @@
1
+ # typed: strict
2
+
1
3
  require 'singleton'
2
4
  require 'rubygems'
5
+ require 'shellwords'
3
6
  require 'tempfile'
4
7
 
5
8
  module Braid
@@ -7,45 +10,64 @@ module Braid
7
10
 
8
11
  module Operations
9
12
  class ShellExecutionError < BraidError
13
+ # TODO (typing): Should this be nilable?
14
+ sig {returns(T.nilable(String))}
10
15
  attr_reader :err, :out
11
16
 
17
+ sig {params(err: T.nilable(String), out: T.nilable(String)).void}
12
18
  def initialize(err = nil, out = nil)
13
19
  @err = err
14
20
  @out = out
15
21
  end
16
22
 
23
+ sig {returns(String)}
17
24
  def message
18
- @err.to_s.split("\n").first
25
+ first_line = @err.to_s.split("\n").first
26
+ # Currently, first_line can be nil if @err was empty, but Sorbet thinks
27
+ # that the `message` method of an Exception should always return non-nil
28
+ # (although override checking isn't enforced as of this writing), so
29
+ # handle nil here. This seems ad-hoc but better than putting in a
30
+ # `T.must` that we know has a risk of being wrong. Hopefully this will
31
+ # be fixed better in https://github.com/cristibalan/braid/issues/90.
32
+ first_line.nil? ? '' : first_line
19
33
  end
20
34
  end
21
35
  class VersionTooLow < BraidError
36
+ sig {params(command: String, version: String, required: String).void}
22
37
  def initialize(command, version, required)
23
38
  @command = command
24
- @version = version.to_s.split("\n").first
39
+ # TODO (typing): Probably should not be nilable
40
+ @version = T.let(version.to_s.split("\n").first, T.nilable(String))
25
41
  @required = required
26
42
  end
27
43
 
44
+ sig {returns(String)}
28
45
  def message
29
46
  "#{@command} version too low: #{@version}. #{@required} needed."
30
47
  end
31
48
  end
32
49
  class UnknownRevision < BraidError
50
+ sig {returns(String)}
33
51
  def message
34
52
  "unknown revision: #{super}"
35
53
  end
36
54
  end
37
55
  class LocalChangesPresent < BraidError
56
+ sig {returns(String)}
38
57
  def message
39
58
  'local changes are present'
40
59
  end
41
60
  end
42
61
  class MergeError < BraidError
62
+ sig {returns(String)}
43
63
  attr_reader :conflicts_text
44
64
 
65
+ sig {params(conflicts_text: String).void}
45
66
  def initialize(conflicts_text)
46
67
  @conflicts_text = conflicts_text
47
68
  end
48
69
 
70
+ sig {returns(String)}
49
71
  def message
50
72
  'could not merge'
51
73
  end
@@ -53,18 +75,24 @@ module Braid
53
75
 
54
76
  # The command proxy is meant to encapsulate commands such as git, that work with subcommands.
55
77
  class Proxy
78
+ extend T::Sig
56
79
  include Singleton
57
80
 
58
- def self.command;
59
- name.split('::').last.downcase;
81
+ # TODO (typing): We could make this method abstract if our fake Sorbet
82
+ # runtime supported abstract methods.
83
+ sig {returns(String)}
84
+ def self.command
85
+ raise InternalError, 'Proxy.command not overridden'
60
86
  end
61
87
 
62
88
  # hax!
89
+ sig {returns(String)}
63
90
  def version
64
- _, out, _ = exec!("#{self.class.command} --version")
91
+ _, out, _ = exec!([self.class.command, '--version'])
65
92
  out.sub(/^.* version/, '').strip.sub(/ .*$/, '').strip
66
93
  end
67
94
 
95
+ sig {params(required: String).returns(T::Boolean)}
68
96
  def require_version(required)
69
97
  # Gem::Version is intended for Ruby gem versions, but various web sites
70
98
  # suggest it as a convenient way of comparing version strings in
@@ -73,77 +101,115 @@ module Braid
73
101
  Gem::Version.new(version) >= Gem::Version.new(required)
74
102
  end
75
103
 
104
+ sig {params(required: String).void}
76
105
  def require_version!(required)
77
106
  require_version(required) || raise(VersionTooLow.new(self.class.command, version, required))
78
107
  end
79
108
 
80
109
  private
81
110
 
111
+ sig {params(name: String).returns(T::Array[String])}
82
112
  def command(name)
83
113
  # stub
84
- name
114
+ [name]
85
115
  end
86
116
 
87
- def invoke(arg, *args)
88
- exec!("#{command(arg)} #{args.join(' ')}".strip)[1].strip # return stdout
117
+ sig {params(arg: String, args: T::Array[String]).returns(String)}
118
+ def invoke(arg, args)
119
+ exec!(command(arg) + args)[1].strip # return stdout
89
120
  end
90
121
 
91
- def method_missing(name, *args)
92
- invoke(name, *args)
93
- end
122
+ # Some of the unit tests want to mock out `exec`, but they have no way to
123
+ # construct a real Process::Status and thus use an integer instead. We
124
+ # have to accommodate this in the type annotation to avoid runtime type
125
+ # check failures during the tests. In normal use of Braid, this will
126
+ # always be a real Process::Status. Fortunately, allowing Integer doesn't
127
+ # seem to cause any other problems right now.
128
+ ProcessStatusOrInteger = T.type_alias { T.any(Process::Status, Integer) }
94
129
 
130
+ sig {params(cmd: T::Array[String]).returns([ProcessStatusOrInteger, String, String])}
95
131
  def exec(cmd)
96
- cmd.strip!
97
-
98
132
  Operations::with_modified_environment({'LANG' => 'C'}) do
99
133
  log(cmd)
100
- out, err, status = Open3.capture3(cmd)
134
+ # The special `[cmd[0], cmd[0]]` syntax ensures that `cmd[0]` is
135
+ # interpreted as the path of the executable and not a shell command
136
+ # even if `cmd` has only one element. See the documentation:
137
+ # https://ruby-doc.org/core-3.1.2/Process.html#method-c-spawn.
138
+ # Granted, this shouldn't matter for Braid for two reasons: (1)
139
+ # `cmd[0]` is always "git", which doesn't contain any shell special
140
+ # characters, and (2) `cmd` always has at least one additional
141
+ # argument (the Git subcommand). However, it's still nice to make our
142
+ # intent clear.
143
+ out, err, status = T.unsafe(Open3).capture3([cmd[0], cmd[0]], *cmd[1..])
101
144
  [status, out, err]
102
145
  end
103
146
  end
104
147
 
148
+ sig {params(cmd: T::Array[String]).returns([ProcessStatusOrInteger, String, String])}
105
149
  def exec!(cmd)
106
150
  status, out, err = exec(cmd)
107
151
  raise ShellExecutionError.new(err, out) unless status == 0
108
152
  [status, out, err]
109
153
  end
110
154
 
155
+ sig {params(cmd: T::Array[String]).returns(ProcessStatusOrInteger)}
111
156
  def system(cmd)
112
- cmd.strip!
113
-
114
157
  # Without this, "braid diff" output came out in the wrong order on Windows.
115
158
  $stdout.flush
116
159
  $stderr.flush
117
160
  Operations::with_modified_environment({'LANG' => 'C'}) do
118
- Kernel.system(cmd)
161
+ # See the comment in `exec` about the `[cmd[0], cmd[0]]` syntax.
162
+ T.unsafe(Kernel).system([cmd[0], cmd[0]], *cmd[1..])
119
163
  return $?
120
164
  end
121
165
  end
122
166
 
167
+ sig {params(str: String).void}
123
168
  def msg(str)
124
169
  puts "Braid: #{str}"
125
170
  end
126
171
 
172
+ sig {params(cmd: T::Array[String]).void}
127
173
  def log(cmd)
128
- msg "Executing `#{cmd}` in #{Dir.pwd}" if verbose?
174
+ # Note: `Shellwords.shelljoin` follows Bourne shell quoting rules, as
175
+ # its documentation states. This may not be what a Windows user
176
+ # expects, but it's not worth the trouble to try to find a library that
177
+ # produces something better on Windows, especially because it's unclear
178
+ # which of Windows's several different quoted formats we would use
179
+ # (e.g., CommandLineToArgvW, cmd.exe, or PowerShell). The most
180
+ # important thing is to use _some_ unambiguous representation.
181
+ msg "Executing `#{Shellwords.shelljoin(cmd)}` in #{Dir.pwd}" if verbose?
129
182
  end
130
183
 
184
+ sig {returns(T::Boolean)}
131
185
  def verbose?
132
186
  Braid.verbose
133
187
  end
134
188
  end
135
189
 
136
190
  class Git < Proxy
191
+
192
+ sig {returns(String)}
193
+ def self.command
194
+ 'git'
195
+ end
196
+
197
+ # A string representing a Git object ID (i.e., hash). This type alias is
198
+ # used as documentation and is not enforced, so there's a risk that we
199
+ # mistakenly mark something as an ObjectID when it can actually be a
200
+ # String that is not an ObjectID.
201
+ ObjectID = T.type_alias { String }
202
+
203
+ # A string containing an expression that can be evaluated to an object ID
204
+ # by `git rev-parse`. Ditto the remark about lack of enforcement.
205
+ ObjectExpr = T.type_alias { String }
206
+
137
207
  # Get the physical path to a file in the git repository (e.g.,
138
208
  # 'MERGE_MSG'), taking into account worktree configuration. The returned
139
209
  # path may be absolute or relative to the current working directory.
210
+ sig {params(path: String).returns(String)}
140
211
  def repo_file_path(path)
141
- if require_version('2.5') # support for --git-path
142
- invoke(:rev_parse, '--git-path', path)
143
- else
144
- # Git < 2.5 doesn't support linked worktrees anyway.
145
- File.join(invoke(:rev_parse, '--git-dir'), path)
146
- end
212
+ invoke('rev-parse', ['--git-path', path])
147
213
  end
148
214
 
149
215
  # If the current directory is not inside a git repository at all, this
@@ -151,8 +217,9 @@ module Braid
151
217
  # propagated as a ShellExecutionError. is_inside_worktree can return
152
218
  # false when inside a bare repository and in certain other rare cases such
153
219
  # as when the GIT_WORK_TREE environment variable is set.
220
+ sig {returns(T::Boolean)}
154
221
  def is_inside_worktree
155
- invoke(:rev_parse, '--is-inside-work-tree') == 'true'
222
+ invoke('rev-parse', ['--is-inside-work-tree']) == 'true'
156
223
  end
157
224
 
158
225
  # Get the prefix of the current directory relative to the worktree. Empty
@@ -160,21 +227,23 @@ module Braid
160
227
  # In some cases in which the current directory is not inside a worktree at
161
228
  # all, this will successfully return an empty string, so it may be
162
229
  # desirable to check is_inside_worktree first.
230
+ sig {returns(String)}
163
231
  def relative_working_dir
164
- invoke(:rev_parse, '--show-prefix')
232
+ invoke('rev-parse', ['--show-prefix'])
165
233
  end
166
234
 
167
- def commit(message, *args)
168
- cmd = 'git commit --no-verify'
235
+ sig {params(message: T.nilable(String), args: T::Array[String]).returns(T::Boolean)}
236
+ def commit(message, args = [])
237
+ cmd = ['git', 'commit', '--no-verify']
169
238
  message_file = nil
170
239
  if message # allow nil
171
240
  message_file = Tempfile.new('braid_commit')
172
241
  message_file.print("Braid: #{message}")
173
242
  message_file.flush
174
243
  message_file.close
175
- cmd << " -F #{message_file.path}"
244
+ cmd += ['-F', T.must(message_file.path)]
176
245
  end
177
- cmd << " #{args.join(' ')}" unless args.empty?
246
+ cmd += args
178
247
  status, out, err = exec(cmd)
179
248
  message_file.unlink if message_file
180
249
 
@@ -187,50 +256,55 @@ module Braid
187
256
  end
188
257
  end
189
258
 
190
- def fetch(remote = nil, *args)
191
- args.unshift "-n #{remote}" if remote
192
- exec!("git fetch #{args.join(' ')}")
193
- end
194
-
195
- def checkout(treeish)
196
- invoke(:checkout, treeish)
197
- true
259
+ sig {params(remote: T.nilable(String), args: T::Array[String]).void}
260
+ def fetch(remote = nil, args = [])
261
+ args = ['-n', remote] + args if remote
262
+ exec!(['git', 'fetch'] + args)
198
263
  end
199
264
 
200
265
  # Returns the base commit or nil.
266
+ sig {params(target: ObjectExpr, source: ObjectExpr).returns(T.nilable(ObjectID))}
201
267
  def merge_base(target, source)
202
- invoke(:merge_base, target, source)
268
+ invoke('merge-base', [target, source])
203
269
  rescue ShellExecutionError
204
270
  nil
205
271
  end
206
272
 
207
- def rev_parse(opt)
208
- invoke(:rev_parse, opt)
273
+ sig {params(expr: ObjectExpr).returns(ObjectID)}
274
+ def rev_parse(expr)
275
+ invoke('rev-parse', [expr])
209
276
  rescue ShellExecutionError
210
- raise UnknownRevision, opt
277
+ raise UnknownRevision, expr
211
278
  end
212
279
 
213
280
  # Implies tracking.
281
+ #
282
+ # TODO (typing): Remove the return value if we're confident that nothing
283
+ # uses it, here and in similar cases.
284
+ sig {params(remote: String, path: String).returns(TrueClass)}
214
285
  def remote_add(remote, path)
215
- invoke(:remote, 'add', remote, path)
286
+ invoke('remote', ['add', remote, path])
216
287
  true
217
288
  end
218
289
 
290
+ sig {params(remote: String).returns(TrueClass)}
219
291
  def remote_rm(remote)
220
- invoke(:remote, 'rm', remote)
292
+ invoke('remote', ['rm', remote])
221
293
  true
222
294
  end
223
295
 
224
296
  # Checks git remotes.
297
+ sig {params(remote: String).returns(T.nilable(String))}
225
298
  def remote_url(remote)
226
299
  key = "remote.#{remote}.url"
227
- invoke(:config, key)
300
+ invoke('config', [key])
228
301
  rescue ShellExecutionError
229
302
  nil
230
303
  end
231
304
 
305
+ sig {params(target: ObjectExpr).returns(TrueClass)}
232
306
  def reset_hard(target)
233
- invoke(:reset, '--hard', target)
307
+ invoke('reset', ['--hard', target])
234
308
  true
235
309
  end
236
310
 
@@ -242,41 +316,59 @@ module Braid
242
316
  # 'recursive' part (i.e., merge of bases) does not come into play and only
243
317
  # the trees matter. But for some reason, Git's smartest tree merge
244
318
  # algorithm is only available via the 'recursive' strategy.
319
+ sig {params(base_treeish: ObjectExpr, local_treeish: ObjectExpr, remote_treeish: ObjectExpr).returns(TrueClass)}
245
320
  def merge_trees(base_treeish, local_treeish, remote_treeish)
246
- invoke(:merge_recursive, base_treeish, "-- #{local_treeish} #{remote_treeish}")
321
+ invoke('merge-recursive', [base_treeish, '--', local_treeish, remote_treeish])
247
322
  true
248
323
  rescue ShellExecutionError => error
249
324
  # 'CONFLICT' messages go to stdout.
250
325
  raise MergeError, error.out
251
326
  end
252
327
 
328
+ sig {params(prefix: String).returns(String)}
253
329
  def read_ls_files(prefix)
254
- invoke('ls-files', prefix)
330
+ invoke('ls-files', [prefix])
255
331
  end
256
332
 
257
333
  class BlobWithMode
334
+ extend T::Sig
335
+ sig {params(hash: ObjectID, mode: String).void}
258
336
  def initialize(hash, mode)
259
337
  @hash = hash
260
338
  @mode = mode
261
339
  end
262
- attr_reader :hash, :mode
340
+ sig {returns(ObjectID)}
341
+ attr_reader :hash
342
+ sig {returns(String)}
343
+ attr_reader :mode
263
344
  end
264
345
  # Allow the class to be referenced as `git.BlobWithMode`.
346
+ sig {returns(T.class_of(BlobWithMode))}
265
347
  def BlobWithMode
266
348
  Git::BlobWithMode
267
349
  end
350
+ # An ObjectID used as a TreeItem represents a tree.
351
+ TreeItem = T.type_alias { T.any(ObjectID, BlobWithMode) }
268
352
 
269
353
  # Get the item at the given path in the given tree. If it's a tree, just
270
354
  # return its hash; if it's a blob, return a BlobWithMode object. (This is
271
355
  # how we remember the mode for single-file mirrors.)
356
+ # TODO (typing): Should `path` be nilable?
357
+ sig {params(tree: ObjectExpr, path: T.nilable(String)).returns(TreeItem)}
272
358
  def get_tree_item(tree, path)
273
359
  if path.nil? || path == ''
274
360
  tree
275
361
  else
276
- m = /^([^ ]*) ([^ ]*) ([^\t]*)\t.*$/.match(invoke(:ls_tree, tree, path))
277
- mode = m[1]
278
- type = m[2]
279
- hash = m[3]
362
+ m = /^([^ ]*) ([^ ]*) ([^\t]*)\t.*$/.match(invoke('ls-tree', [tree, path]))
363
+ if m.nil?
364
+ # This can happen if the user runs `braid add` with a `--path` that
365
+ # doesn't exist. TODO: Make the error message more user-friendly in
366
+ # that case.
367
+ raise ShellExecutionError, 'No tree item exists at the given path'
368
+ end
369
+ mode = T.must(m[1])
370
+ type = T.must(m[2])
371
+ hash = T.must(m[3])
280
372
  if type == 'tree'
281
373
  hash
282
374
  elsif type == 'blob'
@@ -291,37 +383,47 @@ module Braid
291
383
  # path. If update_worktree is true, then update the worktree, otherwise
292
384
  # disregard the state of the worktree (most useful with a temporary index
293
385
  # file).
386
+ sig {params(item: TreeItem, path: String, update_worktree: T::Boolean).void}
294
387
  def add_item_to_index(item, path, update_worktree)
295
388
  if item.is_a?(BlobWithMode)
296
- # Our minimum git version is 1.6.0 and the new --cacheinfo syntax
297
- # wasn't added until 2.0.0.
298
- invoke(:update_index, '--add', '--cacheinfo', item.mode, item.hash, path)
389
+ invoke('update-index', ['--add', '--cacheinfo', "#{item.mode},#{item.hash},#{path}"])
299
390
  if update_worktree
300
391
  # XXX If this fails, we've already updated the index.
301
- invoke(:checkout_index, path)
392
+ invoke('checkout-index', [path])
302
393
  end
303
394
  else
304
395
  # According to
305
396
  # https://lore.kernel.org/git/e48a281a4d3db0a04c0609fcb8658e4fcc797210.1646166271.git.gitgitgadget@gmail.com/,
306
397
  # `--prefix=` is valid if the path is empty.
307
- invoke(:read_tree, "--prefix=#{path}", update_worktree ? '-u' : '-i', item)
398
+ invoke('read-tree', ["--prefix=#{path}", update_worktree ? '-u' : '-i', item])
308
399
  end
309
400
  end
310
401
 
311
402
  # Read tree into the root of the index. This may not be the preferred way
312
403
  # to do it, but it seems to work.
404
+ sig {params(treeish: ObjectExpr).void}
313
405
  def read_tree_im(treeish)
314
- invoke(:read_tree, '-im', treeish)
315
- true
406
+ invoke('read-tree', ['-im', treeish])
407
+ end
408
+
409
+ sig {params(treeish: ObjectExpr).void}
410
+ def read_tree_um(treeish)
411
+ invoke('read-tree', ['-um', treeish])
316
412
  end
317
413
 
318
414
  # Write a tree object for the current index and return its ID.
415
+ sig {returns(ObjectID)}
319
416
  def write_tree
320
- invoke(:write_tree)
417
+ invoke('write-tree', [])
321
418
  end
322
419
 
323
420
  # Execute a block using a temporary git index file, initially empty.
324
- def with_temporary_index
421
+ sig {
422
+ type_parameters(:R).params(
423
+ blk: T.proc.returns(T.type_parameter(:R))
424
+ ).returns(T.type_parameter(:R))
425
+ }
426
+ def with_temporary_index(&blk)
325
427
  Dir.mktmpdir('braid_index') do |dir|
326
428
  Operations::with_modified_environment(
327
429
  {'GIT_INDEX_FILE' => File.join(dir, 'index')}) do
@@ -330,6 +432,7 @@ module Braid
330
432
  end
331
433
  end
332
434
 
435
+ sig {params(main_content: T.nilable(ObjectExpr), item_path: String, item: TreeItem).returns(ObjectID)}
333
436
  def make_tree_with_item(main_content, item_path, item)
334
437
  with_temporary_index do
335
438
  # If item_path is '', then rm_r_cached will fail. But in that case,
@@ -344,103 +447,161 @@ module Braid
344
447
  end
345
448
  end
346
449
 
450
+ sig {params(args: T::Array[String]).returns(T.nilable(String))}
347
451
  def config(args)
348
- invoke(:config, args) rescue nil
452
+ invoke('config', args) rescue nil
349
453
  end
350
454
 
455
+ sig {params(path: String).void}
456
+ def add(path)
457
+ invoke('add', [path])
458
+ end
459
+
460
+ sig {params(path: String).void}
461
+ def rm(path)
462
+ invoke('rm', [path])
463
+ end
464
+
465
+ sig {params(path: String).returns(TrueClass)}
351
466
  def rm_r(path)
352
- invoke(:rm, '-r', path)
467
+ invoke('rm', ['-r', path])
353
468
  true
354
469
  end
355
470
 
356
471
  # Remove from index only.
472
+ sig {params(path: String).returns(TrueClass)}
357
473
  def rm_r_cached(path)
358
- invoke(:rm, '-r', '--cached', path)
474
+ invoke('rm', ['-r', '--cached', path])
359
475
  true
360
476
  end
361
477
 
478
+ sig {params(path: String, treeish: ObjectExpr).returns(ObjectID)}
362
479
  def tree_hash(path, treeish = 'HEAD')
363
- out = invoke(:ls_tree, treeish, '-d', path)
364
- out.split[2]
480
+ out = invoke('ls-tree', [treeish, '-d', path])
481
+ T.must(out.split[2])
482
+ end
483
+
484
+ sig {params(args: T::Array[String]).returns(String)}
485
+ def diff(args)
486
+ invoke('diff', args)
365
487
  end
366
488
 
367
- def diff_to_stdout(*args)
489
+ sig {params(args: T::Array[String]).returns(ProcessStatusOrInteger)}
490
+ def diff_to_stdout(args)
368
491
  # For now, ignore the exit code. It can be 141 (SIGPIPE) if the user
369
492
  # quits the pager before reading all the output.
370
- system("git diff #{args.join(' ')}")
493
+ system(['git', 'diff'] + args)
371
494
  end
372
495
 
496
+ sig {returns(T::Boolean)}
373
497
  def status_clean?
374
- _, out, _ = exec('git status')
498
+ _, out, _ = exec(['git', 'status'])
375
499
  !out.split("\n").grep(/nothing to commit/).empty?
376
500
  end
377
501
 
502
+ sig {void}
378
503
  def ensure_clean!
379
504
  status_clean? || raise(LocalChangesPresent)
380
505
  end
381
506
 
507
+ sig {returns(ObjectID)}
382
508
  def head
383
509
  rev_parse('HEAD')
384
510
  end
385
511
 
386
- def branch
387
- _, out, _ = exec!("git branch | grep '*'")
388
- out[2..-1]
512
+ sig {void}
513
+ def init
514
+ invoke('init', [])
515
+ end
516
+
517
+ sig {params(args: T::Array[String]).void}
518
+ def clone(args)
519
+ invoke('clone', args)
520
+ end
521
+
522
+ # Wrappers for Git commands that were called via `method_missing` before
523
+ # the move to static typing but for which the existing calls don't follow
524
+ # a clear enough pattern around which we could design a narrower API than
525
+ # forwarding an arbitrary argument list. We may narrow the API in the
526
+ # future if it becomes clear what it should be.
527
+
528
+ sig {params(args: T::Array[String]).returns(String)}
529
+ def rev_list(args)
530
+ invoke('rev-list', args)
531
+ end
532
+
533
+ sig {params(args: T::Array[String]).void}
534
+ def update_ref(args)
535
+ invoke('update-ref', args)
536
+ end
537
+
538
+ sig {params(args: T::Array[String]).void}
539
+ def push(args)
540
+ invoke('push', args)
389
541
  end
390
542
 
391
- def clone(*args)
392
- # overrides builtin
393
- invoke(:clone, *args)
543
+ sig {params(args: T::Array[String]).returns(String)}
544
+ def ls_remote(args)
545
+ invoke('ls-remote', args)
394
546
  end
395
547
 
396
548
  private
397
549
 
550
+ sig {params(name: String).returns(T::Array[String])}
398
551
  def command(name)
399
- "#{self.class.command} #{name.to_s.gsub('_', '-')}"
552
+ [self.class.command, name]
400
553
  end
401
554
  end
402
555
 
403
556
  class GitCache
557
+ extend T::Sig
404
558
  include Singleton
405
559
 
560
+ sig {params(url: String).void}
406
561
  def fetch(url)
407
562
  dir = path(url)
408
563
 
409
564
  # remove local cache if it was created with --no-checkout
410
- if File.exists?("#{dir}/.git")
565
+ if File.exist?("#{dir}/.git")
411
566
  FileUtils.rm_r(dir)
412
567
  end
413
568
 
414
- if File.exists?(dir)
569
+ if File.exist?(dir)
415
570
  Dir.chdir(dir) do
416
571
  git.fetch
417
572
  end
418
573
  else
419
574
  FileUtils.mkdir_p(local_cache_dir)
420
- git.clone('--mirror', url, dir)
575
+ git.clone(['--mirror', url, dir])
421
576
  end
422
577
  end
423
578
 
579
+ sig {params(url: String).returns(String)}
424
580
  def path(url)
425
581
  File.join(local_cache_dir, url.gsub(/[\/:@]/, '_'))
426
582
  end
427
583
 
428
584
  private
429
585
 
586
+ sig {returns(String)}
430
587
  def local_cache_dir
431
588
  Braid.local_cache_dir
432
589
  end
433
590
 
591
+ sig {returns(Git)}
434
592
  def git
435
593
  Git.instance
436
594
  end
437
595
  end
438
596
 
439
597
  module VersionControl
598
+ extend T::Sig
599
+ sig {returns(Git)}
440
600
  def git
441
601
  Git.instance
442
602
  end
443
603
 
604
+ sig {returns(GitCache)}
444
605
  def git_cache
445
606
  GitCache.instance
446
607
  end
@@ -36,6 +36,10 @@ module Braid
36
36
  def self.must(value)
37
37
  value
38
38
  end
39
+ def self.unsafe(value)
40
+ value
41
+ end
42
+ def self.bind(value, type); end
39
43
 
40
44
  class FakeType
41
45
  include Singleton
@@ -52,6 +56,12 @@ module Braid
52
56
  def self.untyped
53
57
  FAKE_TYPE
54
58
  end
59
+ def self.noreturn
60
+ FAKE_TYPE
61
+ end
62
+ def self.any(*types)
63
+ FAKE_TYPE
64
+ end
55
65
  Boolean = FAKE_TYPE
56
66
  module Array
57
67
  def self.[](type)
data/lib/braid/version.rb CHANGED
@@ -1,3 +1,6 @@
1
+ # Ditto the comment in `check_gem.rb` regarding the `typed` sigil.
2
+ # typed: false
3
+
1
4
  module Braid
2
- VERSION = '1.1.7'.freeze
5
+ VERSION = '1.1.9'.freeze
3
6
  end
data/lib/braid.rb CHANGED
@@ -12,14 +12,13 @@ module Braid
12
12
  # See the background in the "Supported environments" section of README.md.
13
13
  #
14
14
  # The newest Git feature that Braid is currently known to rely on is
15
- # `receive.denyCurrentBranch = updateInstead` (in
16
- # spec/integration/push_spec.rb), which was added in Git 2.3.0 (in 2015). It
15
+ # `git ls-remote --symref`, which was added in Git 2.8.0 (in 2016). It
17
16
  # doesn't seem worth even a small amount of work to remove that dependency and
18
17
  # support even older versions of Git. So set that as the declared requirement
19
18
  # for now. In general, a reasonable approach might be to try to support the
20
19
  # oldest version of Git in current "long-term support" versions of popular OS
21
20
  # distributions.
22
- REQUIRED_GIT_VERSION = '2.3.0'
21
+ REQUIRED_GIT_VERSION = '2.8.0'
23
22
 
24
23
  @verbose = T.let(false, T::Boolean)
25
24
 
metadata CHANGED
@@ -1,17 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: braid
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.7
4
+ version: 1.1.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cristi Balan
8
8
  - Norbert Crombach
9
9
  - Peter Donald
10
10
  - Matt McCutchen
11
- autorequire:
11
+ autorequire:
12
12
  bindir: exe
13
13
  cert_chain: []
14
- date: 2022-06-15 00:00:00.000000000 Z
14
+ date: 2023-01-11 00:00:00.000000000 Z
15
15
  dependencies:
16
16
  - !ruby/object:Gem::Dependency
17
17
  name: main
@@ -97,6 +97,20 @@ dependencies:
97
97
  - - ">="
98
98
  - !ruby/object:Gem::Version
99
99
  version: '0'
100
+ - !ruby/object:Gem::Dependency
101
+ name: irb
102
+ requirement: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ type: :development
108
+ prerelease: false
109
+ version_requirements: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
100
114
  description: A simple tool for tracking vendor branches in git.
101
115
  email: evil@che.lu norbert.crombach@primetheory.org peter@realityforge.org matt@mattmccutchen.net
102
116
  executables:
@@ -128,7 +142,7 @@ files:
128
142
  homepage: https://github.com/cristibalan/braid
129
143
  licenses: []
130
144
  metadata: {}
131
- post_install_message:
145
+ post_install_message:
132
146
  rdoc_options:
133
147
  - "--line-numbers"
134
148
  - "--inline-source"
@@ -148,8 +162,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
148
162
  - !ruby/object:Gem::Version
149
163
  version: '0'
150
164
  requirements: []
151
- rubygems_version: 3.2.33
152
- signing_key:
165
+ rubygems_version: 3.1.4
166
+ signing_key:
153
167
  specification_version: 4
154
168
  summary: A simple tool for tracking vendor branches in git.
155
169
  test_files: []