slipway 0.1.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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +7 -0
  3. data/CHANGELOG.md +45 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +1013 -0
  6. data/exe/slipway +10 -0
  7. data/lib/slipway/cli/builtins.rb +241 -0
  8. data/lib/slipway/cli/completer.rb +158 -0
  9. data/lib/slipway/cli/completion_scripts.rb +163 -0
  10. data/lib/slipway/cli/context.rb +67 -0
  11. data/lib/slipway/cli/errors.rb +19 -0
  12. data/lib/slipway/cli/globals.rb +27 -0
  13. data/lib/slipway/cli/help_renderer.rb +135 -0
  14. data/lib/slipway/cli/manpage.rb +226 -0
  15. data/lib/slipway/cli/parser.rb +45 -0
  16. data/lib/slipway/cli/registry.rb +191 -0
  17. data/lib/slipway/cli/runner.rb +186 -0
  18. data/lib/slipway/cli/style.rb +82 -0
  19. data/lib/slipway/cli/theme.rb +85 -0
  20. data/lib/slipway/cli/validator.rb +61 -0
  21. data/lib/slipway/cli.rb +22 -0
  22. data/lib/slipway/command_line.rb +22 -0
  23. data/lib/slipway/commands/api_resources.rb +82 -0
  24. data/lib/slipway/commands/apply.rb +172 -0
  25. data/lib/slipway/commands/base.rb +50 -0
  26. data/lib/slipway/commands/config.rb +73 -0
  27. data/lib/slipway/commands/create.rb +218 -0
  28. data/lib/slipway/commands/delete.rb +82 -0
  29. data/lib/slipway/commands/describe.rb +74 -0
  30. data/lib/slipway/commands/diff.rb +122 -0
  31. data/lib/slipway/commands/edit.rb +130 -0
  32. data/lib/slipway/commands/explain.rb +97 -0
  33. data/lib/slipway/commands/fetch.rb +112 -0
  34. data/lib/slipway/commands/from_dir.rb +141 -0
  35. data/lib/slipway/commands/get.rb +167 -0
  36. data/lib/slipway/commands/label.rb +114 -0
  37. data/lib/slipway/commands/manual.rb +67 -0
  38. data/lib/slipway/commands/options.rb +73 -0
  39. data/lib/slipway/commands/results.rb +57 -0
  40. data/lib/slipway/commands/rollout.rb +114 -0
  41. data/lib/slipway/commands/rollout_spec.rb +99 -0
  42. data/lib/slipway/commands/rollout_undo.rb +126 -0
  43. data/lib/slipway/commands/scope.rb +156 -0
  44. data/lib/slipway/commands/sync.rb +140 -0
  45. data/lib/slipway/commands.rb +54 -0
  46. data/lib/slipway/drift.rb +87 -0
  47. data/lib/slipway/editor.rb +71 -0
  48. data/lib/slipway/error.rb +27 -0
  49. data/lib/slipway/fetcher.rb +99 -0
  50. data/lib/slipway/field_selector.rb +86 -0
  51. data/lib/slipway/git/branch_name.rb +32 -0
  52. data/lib/slipway/git/commit.rb +13 -0
  53. data/lib/slipway/git/distance.rb +13 -0
  54. data/lib/slipway/git/errors.rb +125 -0
  55. data/lib/slipway/git/fake.rb +147 -0
  56. data/lib/slipway/git/fast_forward.rb +12 -0
  57. data/lib/slipway/git/fast_forwarding.rb +148 -0
  58. data/lib/slipway/git/fetch_result.rb +22 -0
  59. data/lib/slipway/git/move_back.rb +12 -0
  60. data/lib/slipway/git/reflog.rb +25 -0
  61. data/lib/slipway/git/repository.rb +288 -0
  62. data/lib/slipway/git/rolling_back.rb +98 -0
  63. data/lib/slipway/git/runner.rb +175 -0
  64. data/lib/slipway/git/status.rb +110 -0
  65. data/lib/slipway/git/url.rb +95 -0
  66. data/lib/slipway/git.rb +20 -0
  67. data/lib/slipway/inspector.rb +103 -0
  68. data/lib/slipway/labels.rb +126 -0
  69. data/lib/slipway/manifest.rb +265 -0
  70. data/lib/slipway/names.rb +22 -0
  71. data/lib/slipway/outcome.rb +45 -0
  72. data/lib/slipway/output/age.rb +70 -0
  73. data/lib/slipway/output/describe.rb +71 -0
  74. data/lib/slipway/output/explain.rb +75 -0
  75. data/lib/slipway/output/serializer.rb +35 -0
  76. data/lib/slipway/output/table.rb +67 -0
  77. data/lib/slipway/output.rb +28 -0
  78. data/lib/slipway/paths.rb +65 -0
  79. data/lib/slipway/plan.rb +227 -0
  80. data/lib/slipway/pool.rb +94 -0
  81. data/lib/slipway/resources.rb +91 -0
  82. data/lib/slipway/rollback.rb +236 -0
  83. data/lib/slipway/rollout_history.rb +69 -0
  84. data/lib/slipway/runtime.rb +65 -0
  85. data/lib/slipway/scanner.rb +54 -0
  86. data/lib/slipway/schema.rb +128 -0
  87. data/lib/slipway/selector.rb +146 -0
  88. data/lib/slipway/settings.rb +174 -0
  89. data/lib/slipway/state.rb +82 -0
  90. data/lib/slipway/store.rb +170 -0
  91. data/lib/slipway/syncer.rb +139 -0
  92. data/lib/slipway/version.rb +5 -0
  93. data/lib/slipway/views/group.rb +35 -0
  94. data/lib/slipway/views/project.rb +148 -0
  95. data/lib/slipway/views.rb +10 -0
  96. data/lib/slipway/yaml.rb +14 -0
  97. data/lib/slipway.rb +32 -0
  98. data/man/man1/slipway-api-resources.1 +53 -0
  99. data/man/man1/slipway-apply.1 +45 -0
  100. data/man/man1/slipway-completion.1 +29 -0
  101. data/man/man1/slipway-config-path.1 +20 -0
  102. data/man/man1/slipway-config-view.1 +25 -0
  103. data/man/man1/slipway-config.1 +22 -0
  104. data/man/man1/slipway-create.1 +89 -0
  105. data/man/man1/slipway-delete.1 +46 -0
  106. data/man/man1/slipway-describe.1 +95 -0
  107. data/man/man1/slipway-diff.1 +120 -0
  108. data/man/man1/slipway-edit.1 +34 -0
  109. data/man/man1/slipway-explain.1 +36 -0
  110. data/man/man1/slipway-fetch.1 +77 -0
  111. data/man/man1/slipway-get.1 +155 -0
  112. data/man/man1/slipway-help.1 +19 -0
  113. data/man/man1/slipway-label.1 +53 -0
  114. data/man/man1/slipway-man.1 +36 -0
  115. data/man/man1/slipway-rollout-history.1 +28 -0
  116. data/man/man1/slipway-rollout-pause.1 +21 -0
  117. data/man/man1/slipway-rollout-resume.1 +21 -0
  118. data/man/man1/slipway-rollout-undo.1 +73 -0
  119. data/man/man1/slipway-rollout-unpin.1 +21 -0
  120. data/man/man1/slipway-rollout.1 +36 -0
  121. data/man/man1/slipway-sync.1 +90 -0
  122. data/man/man1/slipway-version.1 +17 -0
  123. data/man/man1/slipway.1 +243 -0
  124. metadata +173 -0
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require_relative 'error'
5
+
6
+ module Slipway
7
+ class Editor
8
+ class Failed < Slipway::Error; end
9
+
10
+ VARIABLE = 'SLIPWAY_EDITOR'
11
+ FALLBACK_VARIABLES = %w[VISUAL EDITOR].freeze
12
+ DEFAULT = 'vi'
13
+ TMPDIR_PREFIX = 'slipway-edit-'
14
+
15
+ # `preferred` is the config file's editor.
16
+ def initialize(env:, preferred: nil)
17
+ @env = env
18
+ @preferred = preferred
19
+ end
20
+
21
+ # An argv Array, so `code --wait` and quoted paths work without a shell.
22
+ def command
23
+ Shellwords.split(command_line)
24
+ rescue ArgumentError => e
25
+ raise Failed, "editor #{command_line.inspect} is not a valid command line: #{e.message}"
26
+ end
27
+
28
+ def edit(text, filename: 'resource.yaml')
29
+ require 'tmpdir'
30
+ Dir.mktmpdir(TMPDIR_PREFIX) do |dir|
31
+ path = File.join(dir, filename)
32
+ File.write(path, text)
33
+ run(command, path)
34
+ read_back(path, filename)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def command_line
41
+ [@env[VARIABLE], @preferred, *FALLBACK_VARIABLES.map { @env[it] }].find { present?(it) } || DEFAULT
42
+ end
43
+
44
+ def present?(value) = !value.nil? && !value.strip.empty?
45
+
46
+ def run(argv, path)
47
+ started = system(*argv, path)
48
+ return if started
49
+
50
+ program = argv.first.inspect
51
+ raise Failed, "editor #{program} not found" if started.nil?
52
+
53
+ raise Failed, "editor #{program} #{describe(Process.last_status)}"
54
+ end
55
+
56
+ def describe(status)
57
+ return "exited with status #{status.exitstatus}" if status.exited?
58
+
59
+ "was killed by signal #{status.termsig}"
60
+ end
61
+
62
+ # An editor may delete or replace the file instead of saving over it.
63
+ def read_back(path, filename)
64
+ File.read(path)
65
+ rescue Errno::ENOENT
66
+ raise Failed, "editor removed #{filename}; edit cancelled"
67
+ rescue SystemCallError => e
68
+ raise Failed.from_system_call(e, path)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slipway
4
+ # Base class for every failure reported to the user. The front controller prints each of
5
+ # `problems` on its own `error:` line.
6
+ class Error < StandardError
7
+ # Ruby appends ` @ rb_sysopen - /path` to a SystemCallError; the user needs the path
8
+ # they typed and the strerror text alone.
9
+ SYSTEM_SUFFIX = / @ \S+ - .*\z/m
10
+
11
+ def self.from_system_call(error, path)
12
+ new("#{path}: #{error.message.sub(SYSTEM_SUFFIX, '')}")
13
+ end
14
+
15
+ def initialize(message = nil, problems: nil)
16
+ @problems = problems&.dup&.freeze
17
+ super(problems ? problems.join("\n") : message)
18
+ end
19
+
20
+ def problems = @problems || [message]
21
+
22
+ def exit_status = 1
23
+
24
+ # Usage errors point at a help page; every other error prints its message alone.
25
+ def hint = nil
26
+ end
27
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'git'
4
+ require_relative 'outcome'
5
+ require_relative 'paths'
6
+
7
+ module Slipway
8
+ # Fetches one project and reports its Outcome. Several workers call it at once.
9
+ class Fetcher
10
+ # With no remote to pick, git fetch exits 0 and prints nothing, which would read as unchanged.
11
+ NO_REMOTE = 'NoRemote'
12
+ NO_REMOTE_DETAIL = 'no upstream, no origin and no single remote to fetch from'
13
+ REF_LIMIT = 5
14
+ ABBREV = Git::Porcelain::ABBREVIATION
15
+ ZERO_ID = /\A0+\z/
16
+ SHORT_REF = %r{\Arefs/(?:remotes|tags|heads)/}
17
+
18
+ def initialize(runtime, prune:, dry_run:)
19
+ @runtime = runtime
20
+ @prune = prune
21
+ @dry_run = dry_run
22
+ @locks = Hash.new { |locks, key| locks[key] = Mutex.new }
23
+ @guard = Mutex.new
24
+ end
25
+
26
+ # The whole of the fetch verb for one project.
27
+ def attempt(project)
28
+ return Outcome.new(project:, word: Outcome::PAUSED) if project.paused
29
+
30
+ inspection = @runtime.inspector.examine(project)
31
+ return Outcome.unreadable(project, inspection) if inspection.error
32
+
33
+ fetch(project, inspection)
34
+ end
35
+
36
+ # `inspection` is the project's, read without an error. An error git reports becomes the
37
+ # outcome; anything else is a bug and leaves through the caller.
38
+ def fetch(project, inspection)
39
+ return no_remote(project) if remoteless?(inspection)
40
+ return rehearse(project) if @dry_run
41
+
42
+ fetched(project, exclusively(project) { @runtime.git.fetch(path(project), prune: @prune) })
43
+ rescue Git::Error => e
44
+ Outcome.failure(project, e)
45
+ end
46
+
47
+ # Two writes into one ref store race on its ref locks, and one of them fails.
48
+ def exclusively(project, &)
49
+ key = @runtime.git.common_dir(path(project))
50
+ @guard.synchronize { @locks[key] }.synchronize(&)
51
+ end
52
+
53
+ def path(project) = Paths.expand(project.path, home: @runtime.paths.home)
54
+
55
+ private
56
+
57
+ # A fetch refuses a branch that tracks a local one before it contacts any remote, so a dry
58
+ # run can tell the same without fetching.
59
+ def rehearse(project)
60
+ raise Git::LocalUpstream, path(project) if @runtime.git.local_upstream?(path(project))
61
+
62
+ Outcome.new(project:, word: Outcome::FETCHED)
63
+ end
64
+
65
+ # An origin or an upstream settles it without a spawn; git is asked only when neither
66
+ # exists, since it may still pick a sole remote.
67
+ def remoteless?(inspection)
68
+ inspection.remote.nil? && inspection.status.upstream.nil? &&
69
+ !@runtime.git.default_remote?(path(inspection.project))
70
+ end
71
+
72
+ def no_remote(project)
73
+ Outcome.new(project:, word: Outcome::SKIPPED, reason: NO_REMOTE, details: [NO_REMOTE_DETAIL])
74
+ end
75
+
76
+ # Git before 2.41 lists no refs, so a fetch that succeeded there reads as fetched.
77
+ def fetched(project, result)
78
+ updates = result.updates
79
+ return Outcome.new(project:, word: Outcome::FETCHED) if updates.nil?
80
+ return Outcome.new(project:, word: Outcome::UNCHANGED) if updates.empty?
81
+
82
+ Outcome.new(project:, word: Outcome::FETCHED, details: refs(updates))
83
+ end
84
+
85
+ def refs(updates)
86
+ lines = updates.first(REF_LIMIT).map { |ref, old_id, new_id| ref_line(ref.sub(SHORT_REF, ''), old_id, new_id) }
87
+ lines << "and #{updates.size - REF_LIMIT} more" if updates.size > REF_LIMIT
88
+ lines
89
+ end
90
+
91
+ # Git writes the all-zero id for the old side of a new ref and the new side of a pruned one.
92
+ def ref_line(name, old_id, new_id)
93
+ return "#{name} #{new_id[0, ABBREV]} (new)" if ZERO_ID.match?(old_id)
94
+ return "#{name} deleted (was #{old_id[0, ABBREV]})" if ZERO_ID.match?(new_id)
95
+
96
+ "#{name} #{old_id[0, ABBREV]}..#{new_id[0, ABBREV]}"
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cli/errors'
4
+
5
+ module Slipway
6
+ # A kubectl field selector, matched against the object json and yaml print. `fields` maps
7
+ # each path a resource supports to the value the path compares as when the object leaves
8
+ # it out.
9
+ class FieldSelector
10
+ Requirement = Data.define(:path, :operator, :value, :absent) do
11
+ def match?(object)
12
+ found = object.dig(*path.split('.'))
13
+ equal = (found.nil? ? absent : found.to_s) == value
14
+ operator == :equals ? equal : !equal
15
+ end
16
+ end
17
+
18
+ class Parser
19
+ # A backslash escapes the character after it, so an escaped comma stays in its term.
20
+ TERM = /(?:\\.|[^\\,])*\\?/m
21
+ # As in kubectl, the leftmost operator splits the term, and at one position != and ==
22
+ # win over =.
23
+ OPERATOR = /\A(.*?)(!=|==|=)(.*)\z/m
24
+ OPERATORS = { '=' => :equals, '==' => :equals, '!=' => :not_equals }.freeze
25
+ ESCAPE = /\\(.?)|=/m
26
+ # The three characters kubectl escapes in a value; any other escape is refused.
27
+ ESCAPABLE = ['\\', ',', '='].freeze
28
+
29
+ # Under the C locale ARGV arrives as BINARY, and a BINARY value holding non-ASCII bytes
30
+ # never equals the UTF-8 string git or the manifest gave, so the bytes are read as UTF-8.
31
+ def initialize(expression, fields)
32
+ @expression = String.new(expression.to_s, encoding: Encoding::UTF_8)
33
+ @fields = fields
34
+ end
35
+
36
+ # kubectl skips empty terms, so a trailing comma is not an error.
37
+ def parse
38
+ fail!('invalid UTF-8') unless @expression.valid_encoding?
39
+
40
+ @expression.strip.scan(TERM).reject(&:empty?).map { requirement(it) }
41
+ end
42
+
43
+ private
44
+
45
+ def requirement(term)
46
+ match = OPERATOR.match(term)
47
+ fail!("can't understand #{term.inspect}") if match.nil?
48
+ path, operator, value = match.captures
49
+ fail!("field label not supported: #{path.inspect}") unless @fields.key?(path)
50
+
51
+ Requirement.new(path:, operator: OPERATORS.fetch(operator), value: unescape(value), absent: @fields.fetch(path))
52
+ end
53
+
54
+ def unescape(value)
55
+ value.gsub(ESCAPE) do |found|
56
+ escaped = Regexp.last_match(1)
57
+ next escaped if ESCAPABLE.include?(escaped)
58
+
59
+ fail!(escaped.nil? ? "unescaped character in value: #{found}" : "invalid escape sequence: #{found}")
60
+ end
61
+ end
62
+
63
+ def fail!(reason)
64
+ raise CLI::UsageError, "invalid field selector #{@expression.inspect}: #{reason}"
65
+ end
66
+ end
67
+
68
+ # nil or a blank string selects everything.
69
+ def self.parse(expression, fields:) = new(Parser.new(expression, fields).parse)
70
+
71
+ def initialize(requirements)
72
+ @requirements = requirements.freeze
73
+ end
74
+
75
+ def empty? = @requirements.empty?
76
+
77
+ def match?(object) = @requirements.all? { it.match?(object) }
78
+
79
+ # The block turns an item into the object its fields are read from.
80
+ def filter(items)
81
+ return items if empty?
82
+
83
+ items.select { match?(yield(it)) }
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../error'
4
+
5
+ module Slipway
6
+ module Git
7
+ # A subset of what `git check-ref-format --branch` accepts, closed so that a name can never
8
+ # read as an option or carry a control character.
9
+ module BranchName
10
+ # Callers decide whether the name came from the command line or a file.
11
+ class Invalid < Slipway::Error; end
12
+
13
+ PATTERN = %r{\A[A-Za-z0-9][A-Za-z0-9._/-]*\z}
14
+ FORBIDDEN = %r{\.\.|//|/\.|\.lock(?:/|\z)|[/.]\z}
15
+ MAX = 255
16
+ RULE = 'letters, digits, ".", "_", "/" and "-", starting with a letter or digit, at most 255 characters, ' \
17
+ 'with no "..", no "//", no component that starts with "." or ends with ".lock", no trailing "/" ' \
18
+ 'or ".", and not HEAD'
19
+
20
+ def self.valid?(name)
21
+ name.is_a?(String) && name.valid_encoding? && name.size <= MAX && name != 'HEAD' &&
22
+ PATTERN.match?(name) && !FORBIDDEN.match?(name)
23
+ end
24
+
25
+ def self.validate!(name)
26
+ return name if valid?(name)
27
+
28
+ raise Invalid, "#{name.scrub.inspect} is not a valid branch name: #{RULE}"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slipway
4
+ module Git
5
+ Commit = Data.define(:sha, :short, :time, :author, :email, :subject) do
6
+ # One record in the field order of Repository::LOG_ARGS.
7
+ def self.parse(text)
8
+ sha, short, epoch, author, email, subject = text.chomp("\0").split("\0", 6)
9
+ new(sha:, short:, time: Time.at(Integer(epoch)).utc, author:, email:, subject: subject.to_s)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slipway
4
+ module Git
5
+ # Where HEAD stands against another commit: `ahead` counts the commits only HEAD reaches,
6
+ # `behind` the ones only the other commit reaches, and `off_upstream` the ones the other commit
7
+ # reaches and @{upstream} does not. That last count is taken only when HEAD is behind the other
8
+ # commit on a branch whose upstream exists, and is nil otherwise.
9
+ Distance = Data.define(:ahead, :behind, :off_upstream) do
10
+ def initialize(ahead:, behind:, off_upstream: nil) = super
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require_relative '../error'
5
+
6
+ module Slipway
7
+ module Git
8
+ class Error < Slipway::Error
9
+ attr_reader :path
10
+
11
+ def initialize(path, detail = 'git command failed')
12
+ @path = path
13
+ super("#{path}: #{detail}")
14
+ end
15
+ end
16
+
17
+ class NotInstalled < Error
18
+ def initialize(path, binary: Runner::DEFAULT_BINARY)
19
+ super(path, "git executable #{binary.inspect} not found on PATH")
20
+ end
21
+ end
22
+
23
+ class Timeout < Error
24
+ DETAIL = 'git did not finish within %<seconds>g %<unit>s'
25
+
26
+ def initialize(path, seconds: Runner::DEFAULT_TIMEOUT)
27
+ unit = seconds == 1 ? 'second' : 'seconds'
28
+ super(path, format(self.class::DETAIL, seconds:, unit:))
29
+ end
30
+ end
31
+
32
+ # Git stopped partway through writing the working tree leaves the branch and the index where
33
+ # they were, but keeps the files it had written, and they then show as changes.
34
+ class WriteTimeout < Timeout
35
+ DETAIL = "#{Timeout::DETAIL}; the files it had written stay in the working tree".freeze
36
+
37
+ def hint = "Run 'git -C #{Shellwords.escape(path)} status' to see them."
38
+ end
39
+
40
+ class MissingPath < Error
41
+ def initialize(path, detail = 'no such directory') = super
42
+ end
43
+
44
+ # A manifest has no directory to resolve a relative path against.
45
+ class RelativePath < MissingPath
46
+ def initialize(path) = super(path, 'relative path; register an absolute path or one starting with ~/')
47
+ end
48
+
49
+ class NotARepository < Error
50
+ def initialize(path) = super(path, 'not a git repository')
51
+ end
52
+
53
+ # Git refuses a repository another user owns unless safe.directory lists it.
54
+ class UnsafeRepository < Error
55
+ def initialize(path) = super(path, 'repository has dubious ownership')
56
+
57
+ # Git's own remedy, so the user can decide whether to trust the directory.
58
+ def command = "git config --global --add safe.directory #{Shellwords.escape(path)}"
59
+
60
+ def hint = "Run '#{command}' to trust it."
61
+ end
62
+
63
+ # Credentials, a passphrase or a host key confirmation were missing or refused, and network
64
+ # commands never prompt for them.
65
+ class AuthRequired < Error
66
+ def initialize(path, detail = 'authentication required and prompts are disabled') = super
67
+
68
+ def hint = "Run 'git -C #{Shellwords.escape(path)} fetch' once in a terminal to see what git needs."
69
+ end
70
+
71
+ class LocalUpstream < Error
72
+ def initialize(path) = super(path, 'the current branch tracks a local branch, not a remote one')
73
+ end
74
+
75
+ # The branch was left where it was. `reason` names why in one word: a state in which a
76
+ # fast-forward is never attempted or, for the subclasses, git's own refusal.
77
+ class Blocked < Error
78
+ attr_reader :reason
79
+
80
+ def initialize(path, reason, detail)
81
+ @reason = reason
82
+ super(path, detail)
83
+ end
84
+ end
85
+
86
+ # Git cannot tell a running process from one that died holding the lock, and neither can
87
+ # slipway, so the lock stays.
88
+ class Busy < Blocked
89
+ def initialize(path, lock = 'index.lock')
90
+ super(path, 'Busy', "another git process holds #{lock}, or one left it behind")
91
+ end
92
+ end
93
+
94
+ class WouldOverwrite < Blocked
95
+ def initialize(path) = super(path, 'WouldOverwrite', 'the incoming commits would overwrite untracked files')
96
+ end
97
+
98
+ class WouldLoseChanges < Blocked
99
+ def initialize(path) = super(path, 'WouldLoseChanges', 'the incoming commits would overwrite local changes')
100
+ end
101
+
102
+ class NotFastForward < Blocked
103
+ def initialize(path) = super(path, 'NotFastForward', 'the branch cannot be fast-forwarded')
104
+ end
105
+
106
+ # Git refuses a transport that GIT_ALLOW_PROTOCOL, built from the "protocols" setting, does not list.
107
+ class ProtocolNotAllowed < Error
108
+ # The transports the "protocols" setting refuses to list, so no hint suggests adding one.
109
+ UNSAFE = %w[ext fd].freeze
110
+
111
+ attr_reader :protocol
112
+
113
+ # `source` says where the transports are listed, worded for the hint.
114
+ def initialize(path, protocol:, source:)
115
+ @protocol = protocol
116
+ @source = source
117
+ super(path, "transport '#{protocol}' not allowed")
118
+ end
119
+
120
+ def hint
121
+ "Add #{protocol} to #{@source} to allow it." unless UNSAFE.include?(protocol)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'fetch_result'
4
+ require_relative 'fast_forward'
5
+ require_relative 'move_back'
6
+
7
+ module Slipway
8
+ module Git
9
+ # `calls` lists every question asked and every move made, as [name, path] or
10
+ # [name, path, options], so a test can tell which repositories a command reached and what it
11
+ # changed.
12
+ class Fake
13
+ Entry = Data.define(:status, :commit, :remote, :remotes, :operation, :fetch, :fetched_at, :fast_forward,
14
+ :distance, :reflog, :between, :roll_back)
15
+
16
+ NOTHING_FETCHED = FetchResult.new(updates: [].freeze)
17
+
18
+ def initialize
19
+ @entries = {}
20
+ @failures = {}
21
+ @calls = []
22
+ @lock = Mutex.new
23
+ end
24
+
25
+ # `fetch` is the FetchResult a fetch returns, and `fast_forward` the FastForward the next
26
+ # move returns (nil moves nothing and names `commit`); either may instead be an error, raised
27
+ # the way fail raises it. `remotes` names the configured remotes, origin alone when `remote`
28
+ # is its URL. `operation` is what in_progress answers, and `distance` what distance answers
29
+ # for any revision (nil: no such commit). `reflog` is what reflog answers for any branch,
30
+ # `between` what commits_between answers for any two commits, and `roll_back` the MoveBack
31
+ # the next roll back returns, or the error it raises.
32
+ def add(path, status:, commit: nil, remote: nil, remotes: nil, operation: nil, fetch: NOTHING_FETCHED,
33
+ fetched_at: nil, fast_forward: nil, distance: nil, reflog: [], between: nil, roll_back: nil)
34
+ key = File.expand_path(path)
35
+ @failures.delete(key)
36
+ remotes ||= remote ? ['origin'] : []
37
+ @entries[key] = Entry.new(status:, commit:, remote:, remotes:, operation:, fetch:, fetched_at:, fast_forward:,
38
+ distance:, reflog:, between:, roll_back:)
39
+ self
40
+ end
41
+
42
+ # `error` is an exception instance or a Git error class that takes the path as its only
43
+ # argument.
44
+ def fail(path, error)
45
+ key = File.expand_path(path)
46
+ @entries.delete(key)
47
+ @failures[key] = error
48
+ self
49
+ end
50
+
51
+ def calls = @lock.synchronize { @calls.dup }
52
+
53
+ def status(path) = entry(:status, path).status
54
+
55
+ def last_commit(path) = entry(:last_commit, path).commit
56
+
57
+ def remote_url(path) = entry(:remote_url, path).remote
58
+
59
+ def in_progress(path) = entry(:in_progress, path).operation
60
+
61
+ def distance(path, revision, tracking: false) = entry(:distance, path, revision:, tracking:).distance
62
+
63
+ def reflog(path, branch) = entry(:reflog, path, branch:).reflog
64
+
65
+ def commits_between(path, from, to) = entry(:commits_between, path, from:, to:).between
66
+
67
+ def fetch(path, prune:)
68
+ outcome = entry(:fetch, path, prune:).fetch
69
+ raise failure(File.expand_path(path), outcome) unless outcome.is_a?(FetchResult)
70
+
71
+ outcome
72
+ end
73
+
74
+ # Read from the canned fetch, since the real fetch raises LocalUpstream exactly when this holds.
75
+ def local_upstream?(path)
76
+ key = File.expand_path(path)
77
+ failure(key, entry(:local_upstream?, key).fetch).is_a?(LocalUpstream)
78
+ end
79
+
80
+ # Git takes the branch's remote, which an upstream implies, else the only remote, else origin.
81
+ def default_remote?(path)
82
+ answer = entry(:default_remote?, path)
83
+ [answer.status.upstream, answer.remote].any? || answer.remotes.size == 1
84
+ end
85
+
86
+ def fetched_at(path) = entry(:fetched_at, path).fetched_at
87
+
88
+ # Each path is a repository of its own.
89
+ def common_dir(path) = File.expand_path(path)
90
+
91
+ # A move happens once, as on a real branch: the status and the last commit then show the new
92
+ # head, that many commits fewer behind, and the next fast-forward moves nothing.
93
+ def fast_forward(path, onto: Repository::UPSTREAM, reflog_action: Repository::REFLOG_ACTION)
94
+ move(:fast_forward, path, FastForward, 1, onto:, reflog_action:)
95
+ end
96
+
97
+ # A move back happens once too, and leaves the branch that many commits further behind.
98
+ def roll_back(path, to:, reflog_action: Repository::ROLLBACK_ACTION)
99
+ move(:roll_back, path, MoveBack, -1, to:, reflog_action:)
100
+ end
101
+
102
+ private
103
+
104
+ def entry(name, path, **) = @lock.synchronize { lookup(name, File.expand_path(path), **) }
105
+
106
+ # A path nobody registered is a path that does not exist, as for the real Repository.
107
+ # Called with the lock held.
108
+ def lookup(name, key, **options)
109
+ @calls << (options.empty? ? [name, key] : [name, key, options])
110
+ raise failure(key, @failures[key]) if @failures.key?(key)
111
+
112
+ @entries.fetch(key) { raise MissingPath, key }
113
+ end
114
+
115
+ def move(name, path, type, direction, **)
116
+ key = File.expand_path(path)
117
+ @lock.synchronize do
118
+ current = lookup(name, key, **)
119
+ move = current.public_send(name) || still(key, current, type)
120
+ raise failure(key, move) unless move.is_a?(type)
121
+
122
+ @entries[key] = moved(current, move, direction * move.count).with(name => nil) if move.moved?
123
+ move
124
+ end
125
+ end
126
+
127
+ # A real move names HEAD in full, as only the last commit does.
128
+ def still(key, entry, type)
129
+ head = entry.commit&.sha
130
+ raise ArgumentError, "#{key}: a move that goes nowhere names the last commit; add one" unless head
131
+
132
+ type.new(from: head, to: head, count: 0)
133
+ end
134
+
135
+ def moved(entry, move, gained)
136
+ status = entry.status
137
+ behind = [status.behind.to_i - gained, 0].max
138
+ short = move.to[0, Porcelain::ABBREVIATION]
139
+ entry.with(status: status.with(head: short, behind:), commit: entry.commit&.with(sha: move.to, short:))
140
+ end
141
+
142
+ def failure(key, error)
143
+ error.is_a?(Class) ? error.new(key) : error
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slipway
4
+ module Git
5
+ # `from` and `to` are the full object names the branch pointed at before and after the move,
6
+ # and `count` the commits it gained: 0, with `to` equal to `from`, when the branch already
7
+ # held the target.
8
+ FastForward = Data.define(:from, :to, :count) do
9
+ def moved? = to != from
10
+ end
11
+ end
12
+ end