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.
- checksums.yaml +7 -0
- data/.yardopts +7 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +1013 -0
- data/exe/slipway +10 -0
- data/lib/slipway/cli/builtins.rb +241 -0
- data/lib/slipway/cli/completer.rb +158 -0
- data/lib/slipway/cli/completion_scripts.rb +163 -0
- data/lib/slipway/cli/context.rb +67 -0
- data/lib/slipway/cli/errors.rb +19 -0
- data/lib/slipway/cli/globals.rb +27 -0
- data/lib/slipway/cli/help_renderer.rb +135 -0
- data/lib/slipway/cli/manpage.rb +226 -0
- data/lib/slipway/cli/parser.rb +45 -0
- data/lib/slipway/cli/registry.rb +191 -0
- data/lib/slipway/cli/runner.rb +186 -0
- data/lib/slipway/cli/style.rb +82 -0
- data/lib/slipway/cli/theme.rb +85 -0
- data/lib/slipway/cli/validator.rb +61 -0
- data/lib/slipway/cli.rb +22 -0
- data/lib/slipway/command_line.rb +22 -0
- data/lib/slipway/commands/api_resources.rb +82 -0
- data/lib/slipway/commands/apply.rb +172 -0
- data/lib/slipway/commands/base.rb +50 -0
- data/lib/slipway/commands/config.rb +73 -0
- data/lib/slipway/commands/create.rb +218 -0
- data/lib/slipway/commands/delete.rb +82 -0
- data/lib/slipway/commands/describe.rb +74 -0
- data/lib/slipway/commands/diff.rb +122 -0
- data/lib/slipway/commands/edit.rb +130 -0
- data/lib/slipway/commands/explain.rb +97 -0
- data/lib/slipway/commands/fetch.rb +112 -0
- data/lib/slipway/commands/from_dir.rb +141 -0
- data/lib/slipway/commands/get.rb +167 -0
- data/lib/slipway/commands/label.rb +114 -0
- data/lib/slipway/commands/manual.rb +67 -0
- data/lib/slipway/commands/options.rb +73 -0
- data/lib/slipway/commands/results.rb +57 -0
- data/lib/slipway/commands/rollout.rb +114 -0
- data/lib/slipway/commands/rollout_spec.rb +99 -0
- data/lib/slipway/commands/rollout_undo.rb +126 -0
- data/lib/slipway/commands/scope.rb +156 -0
- data/lib/slipway/commands/sync.rb +140 -0
- data/lib/slipway/commands.rb +54 -0
- data/lib/slipway/drift.rb +87 -0
- data/lib/slipway/editor.rb +71 -0
- data/lib/slipway/error.rb +27 -0
- data/lib/slipway/fetcher.rb +99 -0
- data/lib/slipway/field_selector.rb +86 -0
- data/lib/slipway/git/branch_name.rb +32 -0
- data/lib/slipway/git/commit.rb +13 -0
- data/lib/slipway/git/distance.rb +13 -0
- data/lib/slipway/git/errors.rb +125 -0
- data/lib/slipway/git/fake.rb +147 -0
- data/lib/slipway/git/fast_forward.rb +12 -0
- data/lib/slipway/git/fast_forwarding.rb +148 -0
- data/lib/slipway/git/fetch_result.rb +22 -0
- data/lib/slipway/git/move_back.rb +12 -0
- data/lib/slipway/git/reflog.rb +25 -0
- data/lib/slipway/git/repository.rb +288 -0
- data/lib/slipway/git/rolling_back.rb +98 -0
- data/lib/slipway/git/runner.rb +175 -0
- data/lib/slipway/git/status.rb +110 -0
- data/lib/slipway/git/url.rb +95 -0
- data/lib/slipway/git.rb +20 -0
- data/lib/slipway/inspector.rb +103 -0
- data/lib/slipway/labels.rb +126 -0
- data/lib/slipway/manifest.rb +265 -0
- data/lib/slipway/names.rb +22 -0
- data/lib/slipway/outcome.rb +45 -0
- data/lib/slipway/output/age.rb +70 -0
- data/lib/slipway/output/describe.rb +71 -0
- data/lib/slipway/output/explain.rb +75 -0
- data/lib/slipway/output/serializer.rb +35 -0
- data/lib/slipway/output/table.rb +67 -0
- data/lib/slipway/output.rb +28 -0
- data/lib/slipway/paths.rb +65 -0
- data/lib/slipway/plan.rb +227 -0
- data/lib/slipway/pool.rb +94 -0
- data/lib/slipway/resources.rb +91 -0
- data/lib/slipway/rollback.rb +236 -0
- data/lib/slipway/rollout_history.rb +69 -0
- data/lib/slipway/runtime.rb +65 -0
- data/lib/slipway/scanner.rb +54 -0
- data/lib/slipway/schema.rb +128 -0
- data/lib/slipway/selector.rb +146 -0
- data/lib/slipway/settings.rb +174 -0
- data/lib/slipway/state.rb +82 -0
- data/lib/slipway/store.rb +170 -0
- data/lib/slipway/syncer.rb +139 -0
- data/lib/slipway/version.rb +5 -0
- data/lib/slipway/views/group.rb +35 -0
- data/lib/slipway/views/project.rb +148 -0
- data/lib/slipway/views.rb +10 -0
- data/lib/slipway/yaml.rb +14 -0
- data/lib/slipway.rb +32 -0
- data/man/man1/slipway-api-resources.1 +53 -0
- data/man/man1/slipway-apply.1 +45 -0
- data/man/man1/slipway-completion.1 +29 -0
- data/man/man1/slipway-config-path.1 +20 -0
- data/man/man1/slipway-config-view.1 +25 -0
- data/man/man1/slipway-config.1 +22 -0
- data/man/man1/slipway-create.1 +89 -0
- data/man/man1/slipway-delete.1 +46 -0
- data/man/man1/slipway-describe.1 +95 -0
- data/man/man1/slipway-diff.1 +120 -0
- data/man/man1/slipway-edit.1 +34 -0
- data/man/man1/slipway-explain.1 +36 -0
- data/man/man1/slipway-fetch.1 +77 -0
- data/man/man1/slipway-get.1 +155 -0
- data/man/man1/slipway-help.1 +19 -0
- data/man/man1/slipway-label.1 +53 -0
- data/man/man1/slipway-man.1 +36 -0
- data/man/man1/slipway-rollout-history.1 +28 -0
- data/man/man1/slipway-rollout-pause.1 +21 -0
- data/man/man1/slipway-rollout-resume.1 +21 -0
- data/man/man1/slipway-rollout-undo.1 +73 -0
- data/man/man1/slipway-rollout-unpin.1 +21 -0
- data/man/man1/slipway-rollout.1 +36 -0
- data/man/man1/slipway-sync.1 +90 -0
- data/man/man1/slipway-version.1 +17 -0
- data/man/man1/slipway.1 +243 -0
- 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
|