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,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slipway
4
+ module Git
5
+ # The one place that spawns git.
6
+ class Runner
7
+ Result = Data.define(:status, :out, :err) do
8
+ def success? = status.zero?
9
+ end
10
+
11
+ DEFAULT_BINARY = 'git'
12
+ DEFAULT_TIMEOUT = 10.0
13
+ # Git deletes its lock files when TERM arrives, and KILL cannot be caught: a git killed
14
+ # while it updates refs would leave locks that fail every later command that moves them.
15
+ TERM_GRACE = 1.0
16
+ # Shell convention for a process ended by a signal: 128 plus the signal number.
17
+ SIGNAL_STATUS_BASE = 128
18
+ CEILING_VARIABLE = 'GIT_CEILING_DIRECTORIES'
19
+ PROTOCOL_VARIABLE = 'GIT_ALLOW_PROTOCOL'
20
+ # Absolute, so no program named false earlier on PATH stands in for it; macOS has only
21
+ # /usr/bin/false.
22
+ FALSE_PROGRAMS = %w[/usr/bin/false /bin/false].freeze
23
+
24
+ # LC_ALL=C keeps messages parseable; prompts and optional index writes are disabled; every
25
+ # repository-selecting variable is unset so an inherited GIT_DIR cannot redirect a query.
26
+ ENVIRONMENT = {
27
+ 'LC_ALL' => 'C',
28
+ 'LANGUAGE' => nil,
29
+ 'GIT_TERMINAL_PROMPT' => '0',
30
+ 'GIT_OPTIONAL_LOCKS' => '0',
31
+ 'GIT_PAGER' => 'cat',
32
+ 'GIT_DIR' => nil,
33
+ 'GIT_WORK_TREE' => nil,
34
+ 'GIT_INDEX_FILE' => nil,
35
+ 'GIT_NAMESPACE' => nil,
36
+ 'GIT_OBJECT_DIRECTORY' => nil,
37
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES' => nil
38
+ }.freeze
39
+
40
+ # A path that names no program still fails the prompt, which is all the askpass is for.
41
+ def self.false_program(candidates = FALSE_PROGRAMS) = candidates.find { File.executable?(it) } || candidates.first
42
+
43
+ FALSE_PROGRAM = false_program
44
+
45
+ # Every credential or host key question is answered by false, so git and ssh fail at once
46
+ # instead of waiting on a prompt nobody sees. SSH_ASKPASS_REQUIRE=force (OpenSSH 8.4 and
47
+ # later) sends every ssh prompt to the askpass, with or without DISPLAY, so ssh never reads
48
+ # /dev/tty.
49
+ NETWORK_ENVIRONMENT = {
50
+ 'GIT_ASKPASS' => FALSE_PROGRAM,
51
+ 'SSH_ASKPASS' => FALSE_PROGRAM,
52
+ 'SSH_ASKPASS_REQUIRE' => 'force'
53
+ }.freeze
54
+
55
+ # No gc or maintenance started on the user's behalf, and no bundle download from a URL the
56
+ # server advertises.
57
+ NETWORK_CONFIG = %w[-c gc.auto=0 -c maintenance.auto=false -c transfer.bundleURI=false].freeze
58
+
59
+ # GIT_ALLOW_PROTOCOL refuses every transport it does not list, including file and ext.
60
+ def self.network_environment(protocols) = NETWORK_ENVIRONMENT.merge(PROTOCOL_VARIABLE => protocols.join(':'))
61
+
62
+ attr_reader :binary, :timeout
63
+
64
+ def initialize(binary: DEFAULT_BINARY, timeout: DEFAULT_TIMEOUT)
65
+ @binary = binary
66
+ @timeout = timeout
67
+ end
68
+
69
+ # Returns a Result whatever git's exit status is. Raises NotInstalled when the binary
70
+ # cannot be started and Timeout when `timeout` seconds pass. `env` adds variables to the
71
+ # child; it cannot change the ones ENVIRONMENT pins.
72
+ # A git still running when the block unwinds for any other reason, such as an
73
+ # interrupt, is killed with its process group instead of outliving the command.
74
+ def run(path, *, timeout: @timeout, env: {})
75
+ require 'open3'
76
+ directory = File.expand_path(path)
77
+ # Open3 spawns git before this block's ensure is armed, so an interrupt or a Thread#kill
78
+ # (not an Exception, hence Object) landing in between would leave it running; both are
79
+ # held until the block runs.
80
+ Thread.handle_interrupt(Object => :never) do
81
+ Open3.popen3(environment(directory, env), @binary, '-C', directory, *, pgroup: true) do |stdin, *pipes, wait|
82
+ stdin.close
83
+ readers = pipes.map { reader(it) }
84
+ Thread.handle_interrupt(Object => :immediate) { collect(wait, readers, directory, timeout) }
85
+ ensure
86
+ reap(wait, Array(readers))
87
+ end
88
+ end
89
+ rescue Errno::ENOENT
90
+ raise NotInstalled.new(directory, binary: @binary)
91
+ end
92
+
93
+ private
94
+
95
+ # A helper git started can hold a pipe open after git exits, so the deadline covers the
96
+ # pipes too.
97
+ def collect(wait, readers, directory, timeout)
98
+ unless settled?([wait, *readers], timeout)
99
+ stop(wait, readers)
100
+ raise Timeout.new(directory, seconds: timeout)
101
+ end
102
+ out, err = readers.map { scrub(it.value) }
103
+ Result.new(status: exit_status(wait.value), out:, err:)
104
+ end
105
+
106
+ # Discovery must not climb above the registered directory, or a subdirectory of some
107
+ # other repository would report that repository's state. Git compares real paths, so
108
+ # the ceiling is the real parent.
109
+ def environment(directory, extra)
110
+ extra.merge(ENVIRONMENT, CEILING_VARIABLE => File.dirname(realpath(directory)))
111
+ end
112
+
113
+ def realpath(directory)
114
+ File.realpath(directory)
115
+ rescue SystemCallError
116
+ directory
117
+ end
118
+
119
+ # Drains one pipe on its own thread so a chatty command cannot deadlock on a full buffer.
120
+ # A new thread inherits the interrupts run holds; the read lets them in again so stop can
121
+ # kill it. Its result is only taken through value, which re-raises, so a reader whose pipe
122
+ # is closed under it stays quiet instead of printing a thread trace.
123
+ def reader(io)
124
+ io.binmode
125
+ Thread.new do
126
+ Thread.current.report_on_exception = false
127
+ Thread.handle_interrupt(Object => :immediate) { io.read }
128
+ end
129
+ end
130
+
131
+ def settled?(threads, timeout)
132
+ deadline = clock + timeout
133
+ threads.all? { it.join([deadline - clock, 0].max) }
134
+ end
135
+
136
+ def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
137
+
138
+ # This runs with interrupts held, and the thread Open3 waits for git on inherited that
139
+ # mask, so neither another interrupt nor process exit can end them while git lives.
140
+ def reap(wait, readers)
141
+ stop(wait, readers) if wait.alive? || readers.any?(&:alive?)
142
+ end
143
+
144
+ # KILL follows as soon as git exits or the grace ends, so a helper that ignores TERM dies
145
+ # even when git has already exited. The readers are stopped, not drained: a helper that left
146
+ # the group can hold a pipe open forever.
147
+ def stop(wait, readers)
148
+ signal(wait, 'TERM')
149
+ wait.join(TERM_GRACE)
150
+ signal(wait, 'KILL')
151
+ wait.join
152
+ readers.each { it.kill.join }
153
+ end
154
+
155
+ # macOS answers EPERM, not ESRCH, when every process left in the group has exited but is
156
+ # not yet reaped. git runs as this user, so either answer means nothing is left to signal,
157
+ # and raising would replace the interrupt, kill or timeout that is ending the run.
158
+ def signal(wait, name)
159
+ Process.kill(name, -wait.pid)
160
+ rescue Errno::ESRCH, Errno::EPERM
161
+ nil
162
+ end
163
+
164
+ def exit_status(process)
165
+ process.exitstatus || (SIGNAL_STATUS_BASE + process.termsig)
166
+ end
167
+
168
+ # Paths in git output are raw bytes; invalid sequences become U+FFFD so every consumer
169
+ # gets a valid UTF-8 string.
170
+ def scrub(bytes)
171
+ bytes.force_encoding(Encoding::UTF_8).scrub
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slipway
4
+ module Git
5
+ Status = Data.define(:branch, :head, :upstream, :ahead, :behind, :upstream_gone,
6
+ :staged, :unstaged, :untracked, :conflicted, :stashes) do
7
+ # Parses the output of `git status --porcelain=v2 --branch --show-stash -z`.
8
+ def self.parse(text) = Porcelain.new.parse(text)
9
+
10
+ def initialize(head:, branch: 'main', upstream: nil, ahead: nil, behind: nil, upstream_gone: false,
11
+ staged: 0, unstaged: 0, untracked: 0, conflicted: 0, stashes: 0)
12
+ super
13
+ end
14
+
15
+ def detached? = branch.nil?
16
+
17
+ def unborn? = head.nil?
18
+
19
+ def upstream_gone? = upstream_gone
20
+
21
+ def tracking? = !detached? && !upstream.nil? && !upstream_gone?
22
+
23
+ # Stashes do not count.
24
+ def clean? = staged.zero? && unstaged.zero? && untracked.zero? && conflicted.zero?
25
+ end
26
+
27
+ class Porcelain
28
+ INITIAL = '(initial)'
29
+ DETACHED = '(detached)'
30
+ # Porcelain prints the full object id; the table shows what `git log --oneline` would.
31
+ ABBREVIATION = 7
32
+ UNCHANGED = '.'
33
+
34
+ def initialize
35
+ @branch = nil
36
+ @head = nil
37
+ @upstream = nil
38
+ @ahead = nil
39
+ @behind = nil
40
+ @position_seen = false
41
+ @stashes = 0
42
+ @counts = Hash.new(0)
43
+ end
44
+
45
+ def parse(text)
46
+ records = text.split("\0")
47
+ until records.empty?
48
+ record = records.shift
49
+ # A rename or copy record is followed by the original path as its own field.
50
+ records.shift if record.start_with?('2 ')
51
+ consume(record)
52
+ end
53
+ status
54
+ end
55
+
56
+ private
57
+
58
+ def status
59
+ Status.new(branch: @branch, head: @head, upstream: @upstream, ahead: @ahead, behind: @behind,
60
+ # git omits branch.ab when the upstream is set but its ref is gone.
61
+ upstream_gone: !@upstream.nil? && !@position_seen,
62
+ staged: @counts[:staged], unstaged: @counts[:unstaged],
63
+ untracked: @counts[:untracked], conflicted: @counts[:conflicted], stashes: @stashes)
64
+ end
65
+
66
+ def consume(record)
67
+ case record
68
+ when /\A# / then header(record)
69
+ when /\A[12] / then change(record[2], record[3])
70
+ when /\Au / then @counts[:conflicted] += 1
71
+ when /\A\? / then @counts[:untracked] += 1
72
+ end
73
+ end
74
+
75
+ def header(record)
76
+ key, value = record.delete_prefix('# ').split(' ', 2)
77
+ if key == 'stash'
78
+ @stashes = Integer(value)
79
+ elsif key.start_with?('branch.')
80
+ branch(key.delete_prefix('branch.'), value)
81
+ end
82
+ end
83
+
84
+ def branch(field, value)
85
+ case field
86
+ when 'oid' then @head = value[0, ABBREVIATION] unless value == INITIAL
87
+ when 'head' then @branch = value unless value == DETACHED
88
+ when 'upstream' then @upstream = value
89
+ when 'ab' then position(value)
90
+ end
91
+ end
92
+
93
+ # `+<ahead> -<behind>`; git prints `+? -?` when it was told not to count.
94
+ def position(value)
95
+ @position_seen = true
96
+ @ahead, @behind = value.split.map { count(it) }
97
+ end
98
+
99
+ def count(token)
100
+ digits = token.delete_prefix('+').delete_prefix('-')
101
+ digits == '?' ? nil : Integer(digits)
102
+ end
103
+
104
+ def change(index, worktree)
105
+ @counts[:staged] += 1 unless index == UNCHANGED
106
+ @counts[:unstaged] += 1 unless worktree == UNCHANGED
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../error'
4
+
5
+ module Slipway
6
+ module Git
7
+ # Remote URLs: the forms slipway accepts (validate!, valid?) and the masking of credentials
8
+ # in any text that may quote one (redact).
9
+ module Url
10
+ # Callers decide whether the URL came from the command line or a file.
11
+ class Invalid < Slipway::Error; end
12
+
13
+ # The userinfo runs to the last "@" before the authority ends, so a password holding a raw
14
+ # "@" is covered whole.
15
+ USERINFO = %r{(?<scheme>[A-Za-z][A-Za-z0-9+.-]*)://(?<userinfo>[^\s/?#]+)@}
16
+ # Over http a token is often sent as the user name, so the whole userinfo is secret there,
17
+ # and in a scheme that carries http, such as git+https or persistent-https.
18
+ WHOLE_USERINFO = /(?:\A|[+-])https?\z/i
19
+ LEADING_USERINFO = /\A#{USERINFO}/
20
+ MASK = '***'
21
+
22
+ MAX = 2048
23
+ TEXT = /\A[^\p{C}\p{Z}]+\z/
24
+ HOST = /[A-Za-z0-9][A-Za-z0-9.-]*/
25
+ USER = /[A-Za-z0-9_][A-Za-z0-9._-]*/
26
+ # git reads "<name>::" as a transport helper and "<name>://" as a URL, so an scp-like path
27
+ # starting with either is not a path.
28
+ FORMS = [
29
+ %r{\A(?:ssh|https?|git)://(?:#{USER}@)?#{HOST}(?::\d{1,5})?/},
30
+ %r{\Afile://(?:#{HOST})?/},
31
+ %r{\A(?:#{USER}@)?#{HOST}:(?!:|//).}
32
+ ].freeze
33
+ # git takes "user:secret@host:path" as the host "user", but whoever reads it sees a password.
34
+ SCP_PASSWORD = %r{\A[^/@]*:(?!//)[^@]*@}
35
+ # A password holding "/", "?", "#" or whitespace ends the userinfo early for #redact and
36
+ # makes the remote invalid, so it is caught only once the remote is refused. A token before
37
+ # the "@" has no ":" to tell it from a user name, so a refused remote with an "@" past its
38
+ # scheme is never quoted.
39
+ URL_PASSWORD = %r{\A[A-Za-z][A-Za-z0-9+.-]*://[^@:/]*:.*@}m
40
+ AFTER_SCHEME_AT = %r{://[^@]*@}
41
+ RULE = 'scheme://host/path with scheme ssh, https, http, git or file, or [user@]host:path, where the ' \
42
+ 'host is letters, digits, dots and dashes starting with a letter or digit; at most 2048 characters, ' \
43
+ 'without whitespace or control characters'
44
+ CREDENTIALS = 'must not embed credentials; use a credential helper'
45
+
46
+ # `text` is free text, such as a line of git's stderr: only scheme://userinfo@ URLs change,
47
+ # so scp-like addresses (git@host:path) and e-mail addresses stay as they are.
48
+ def self.redact(text)
49
+ text.gsub(USERINFO) do
50
+ match = Regexp.last_match
51
+ "#{match[:scheme]}://#{mask(match[:scheme], match[:userinfo])}@"
52
+ end
53
+ end
54
+
55
+ # Drops what #redact would mask, so a remote read from a clone keeps its address and a
56
+ # credential helper supplies what was dropped.
57
+ def self.without_credentials(url)
58
+ url.sub(LEADING_USERINFO) do
59
+ match = Regexp.last_match
60
+ user = WHOLE_USERINFO.match?(match[:scheme]) ? '' : match[:userinfo].partition(':').first
61
+ user.empty? ? "#{match[:scheme]}://" : "#{match[:scheme]}://#{user}@"
62
+ end
63
+ end
64
+
65
+ # A remote is accepted only when #redact would leave it as it is, so describe, json and
66
+ # yaml can print it whole. The command line may label UTF-8 bytes as US-ASCII in the C
67
+ # locale, so the value is read, and returned, as UTF-8.
68
+ def self.validate!(url, field:)
69
+ text = url.dup.force_encoding(Encoding::UTF_8)
70
+ shown = text.scrub
71
+ raise Invalid, "#{field} #{CREDENTIALS}" if credentials?(shown)
72
+ return text if valid?(text)
73
+ raise Invalid, "#{field} #{CREDENTIALS}" if URL_PASSWORD.match?(shown)
74
+
75
+ quoted = AFTER_SCHEME_AT.match?(shown) ? field : shown.inspect
76
+ raise Invalid, "#{quoted} is not a valid remote URL: #{RULE}"
77
+ end
78
+
79
+ def self.mask(scheme, userinfo)
80
+ return MASK if WHOLE_USERINFO.match?(scheme)
81
+
82
+ user, colon, = userinfo.partition(':')
83
+ colon.empty? ? userinfo : "#{user}:#{MASK}"
84
+ end
85
+
86
+ def self.credentials?(text) = redact(text) != text || SCP_PASSWORD.match?(text)
87
+
88
+ def self.valid?(text)
89
+ text.valid_encoding? && text.size <= MAX && TEXT.match?(text) && FORMS.any? { it.match?(text) }
90
+ end
91
+
92
+ private_class_method :mask, :credentials?, :valid?
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'git/errors'
4
+ require_relative 'git/url'
5
+ require_relative 'git/branch_name'
6
+ require_relative 'git/runner'
7
+ require_relative 'git/status'
8
+ require_relative 'git/commit'
9
+ require_relative 'git/fetch_result'
10
+ require_relative 'git/fast_forward'
11
+ require_relative 'git/distance'
12
+ require_relative 'git/reflog'
13
+ require_relative 'git/move_back'
14
+ require_relative 'git/repository'
15
+ require_relative 'git/fake'
16
+
17
+ module Slipway
18
+ module Git
19
+ end
20
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'git'
4
+ require_relative 'paths'
5
+ require_relative 'plan'
6
+ require_relative 'pool'
7
+ require_relative 'state'
8
+
9
+ module Slipway
10
+ # `status`, `commit` and `remote` are nil when git could not answer; `error` then holds the
11
+ # Git error and `state` its word. `fetched_at` is nil then too, and for a repository no fetch
12
+ # has reached. `operation` is the merge, rebase or other operation in progress, asked only of a
13
+ # project the plan would fast-forward. `pin` is how far HEAD is from spec.revision, a
14
+ # Git::Distance or nil when the repository lacks that commit, asked only of a pinned project
15
+ # whose HEAD is elsewhere.
16
+ Inspection = Data.define(:project, :status, :commit, :remote, :fetched_at, :state, :error, :operation, :pin) do
17
+ def initialize(operation: nil, pin: nil, **) = super
18
+
19
+ # A pin may name an annotated tag, which git peels to the commit it tags, so HEAD is also at
20
+ # the pin when git counts no commit between them.
21
+ def at_pin?
22
+ return false if commit.nil? || project.revision.nil?
23
+
24
+ commit.sha == project.revision || (!pin.nil? && pin.ahead.zero? && pin.behind.zero?)
25
+ end
26
+
27
+ def self.failed(project, error)
28
+ new(project:, status: nil, commit: nil, remote: nil, fetched_at: nil, state: State.for_error(error), error:)
29
+ end
30
+ end
31
+
32
+ # A leading ~ in a project path is `home`, the HOME of the environment the command runs in,
33
+ # so a manifest written as ~/dev/x means the same thing whoever runs it.
34
+ class Inspector
35
+ # One warning per distinct reason a project came back Unknown: the message without its
36
+ # path, so twenty projects without git raise one line.
37
+ Batch = Data.define(:inspections, :warnings)
38
+
39
+ DEFAULT_WORKERS = 8
40
+ UNKNOWN = 'Unknown'
41
+
42
+ def initialize(git:, clock:, home: Dir.home, workers: DEFAULT_WORKERS)
43
+ @git = git
44
+ @clock = clock
45
+ @home = home
46
+ @pool = Pool.new(workers:)
47
+ end
48
+
49
+ def workers = @pool.workers
50
+
51
+ # A relative path has no directory to be relative to and a path that is not a directory
52
+ # needs no git; both are Missing. Any other failure, git's or not, becomes the
53
+ # Inspection's error rather than escaping the worker.
54
+ def examine(project)
55
+ path = Paths.expand(project.path, home: @home)
56
+ return Inspection.failed(project, Git::RelativePath.new(path)) unless File.absolute_path?(path)
57
+ return Inspection.failed(project, Git::MissingPath.new(path)) unless File.directory?(path)
58
+
59
+ with_operation(with_pin(Inspection.new(project:, **query(path), error: nil), path), path)
60
+ rescue Git::Error => e
61
+ Inspection.failed(project, e)
62
+ rescue StandardError => e
63
+ Inspection.failed(project, Git::Error.new(path, "git could not be read: #{e.class}: #{e.message}"))
64
+ end
65
+
66
+ def examine_all(projects)
67
+ results = @pool.map(projects) { examine(it) }.freeze
68
+ Batch.new(inspections: results, warnings: collect_warnings(results))
69
+ end
70
+
71
+ private
72
+
73
+ def query(path)
74
+ status = @git.status(path)
75
+ commit = status.unborn? ? nil : @git.last_commit(path)
76
+ { status:, commit:, remote: @git.remote_url(path), fetched_at: @git.fetched_at(path),
77
+ state: State.derive(status) }
78
+ end
79
+
80
+ # One more spawn, paid only by a pinned project whose HEAD is not the pinned commit, and a
81
+ # second when HEAD is behind the pin on a branch whose upstream exists.
82
+ def with_pin(inspection, path)
83
+ revision = inspection.project.revision
84
+ sha = inspection.commit&.sha
85
+ return inspection if revision.nil? || sha.nil? || sha == revision
86
+
87
+ inspection.with(pin: @git.distance(path, revision, tracking: inspection.status.tracking?))
88
+ end
89
+
90
+ # One more spawn, paid only by a project that would otherwise be fast-forwarded.
91
+ def with_operation(inspection, path)
92
+ return inspection unless Plan.for(inspection).fast_forward?
93
+
94
+ inspection.with(operation: @git.in_progress(path))
95
+ end
96
+
97
+ def collect_warnings(results)
98
+ results.select { it.state == UNKNOWN }.map { reason(it.error) }.uniq.freeze
99
+ end
100
+
101
+ def reason(error) = error.message.delete_prefix("#{error.path}: ")
102
+ end
103
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cli/errors'
4
+
5
+ module Slipway
6
+ # Label keys and values as Kubernetes defines them.
7
+ module Labels
8
+ # Callers decide whether the label came from the command line or a file.
9
+ class Invalid < Error; end
10
+
11
+ NAME = /\A[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?\z/
12
+ PREFIX = /\A[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\z/
13
+ MAX_NAME = 63
14
+ MAX_PREFIX = 253
15
+ KEY_RULE = 'letters, digits, dashes, underscores and dots, starting and ending with a letter or digit, ' \
16
+ 'at most 63 characters, with an optional DNS subdomain prefix and a slash'
17
+ VALUE_RULE = 'empty, or letters, digits, dashes, underscores and dots, starting and ending with a letter ' \
18
+ 'or digit, at most 63 characters'
19
+
20
+ def self.valid_key?(key)
21
+ return false unless key.is_a?(String)
22
+
23
+ case key.split('/', -1)
24
+ in [name] then valid_name?(name)
25
+ in [prefix, name] then valid_prefix?(prefix) && valid_name?(name)
26
+ else false
27
+ end
28
+ end
29
+
30
+ def self.valid_value?(value) = value.is_a?(String) && (value.empty? || valid_name?(value))
31
+
32
+ def self.validate_key!(key)
33
+ return key if valid_key?(key)
34
+
35
+ raise Invalid, "#{key.inspect} is not a valid label key: #{KEY_RULE}"
36
+ end
37
+
38
+ def self.validate_value!(value)
39
+ return value if valid_value?(value)
40
+
41
+ raise Invalid, "#{value.inspect} is not a valid label value: #{VALUE_RULE}"
42
+ end
43
+
44
+ def self.validate!(labels)
45
+ labels.each do |key, value|
46
+ validate_key!(key)
47
+ validate_value!(value)
48
+ end
49
+ end
50
+
51
+ def self.parse_pairs(words)
52
+ words.each_with_object({}) do |word, pairs|
53
+ key, value = split_pair(word, 'KEY=VALUE')
54
+ duplicate!(key) if pairs.key?(key)
55
+ pairs[key] = value
56
+ end
57
+ end
58
+
59
+ # KEY=VALUE sets a label and KEY- removes one; returns [sets, removals].
60
+ def self.parse_changes(words)
61
+ sets = {}
62
+ removals = []
63
+ words.each do |word|
64
+ if removal?(word)
65
+ removals << removal_key(word, removals)
66
+ else
67
+ key, value = split_pair(word, 'KEY=VALUE or KEY-')
68
+ duplicate!(key) if sets.key?(key)
69
+ sets[key] = value
70
+ end
71
+ end
72
+ reject_overlap(sets, removals)
73
+ end
74
+
75
+ # The form kubectl prints labels in.
76
+ def self.format(labels)
77
+ return nil if labels.empty?
78
+
79
+ labels.sort.map { |key, value| "#{key}=#{value}" }.join(',')
80
+ end
81
+
82
+ def self.valid_name?(name) = name.length <= MAX_NAME && NAME.match?(name)
83
+
84
+ def self.valid_prefix?(prefix) = prefix.length <= MAX_PREFIX && PREFIX.match?(prefix)
85
+
86
+ def self.split_pair(word, expected)
87
+ key, value = word.split('=', 2)
88
+ raise CLI::UsageError, "invalid label #{word.inspect}: expected #{expected}" if value.nil?
89
+
90
+ as_usage_error do
91
+ validate_key!(key)
92
+ validate_value!(value)
93
+ end
94
+ [key, value]
95
+ end
96
+
97
+ def self.removal?(word) = word.end_with?('-') && !word.include?('=')
98
+
99
+ def self.removal_key(word, removals)
100
+ key = as_usage_error { validate_key!(word.delete_suffix('-')) }
101
+ duplicate!(key) if removals.include?(key)
102
+ key
103
+ end
104
+
105
+ def self.duplicate!(key)
106
+ raise CLI::UsageError, "label #{key.inspect} is given more than once"
107
+ end
108
+
109
+ def self.reject_overlap(sets, removals)
110
+ overlap = sets.keys & removals
111
+ return [sets, removals] if overlap.empty?
112
+
113
+ raise CLI::UsageError, "label #{overlap.first.inspect} cannot be both set and removed"
114
+ end
115
+
116
+ # Label words come from the command line, so a bad key or value is a usage error there.
117
+ def self.as_usage_error
118
+ yield
119
+ rescue Invalid => e
120
+ raise CLI::UsageError, e.message
121
+ end
122
+
123
+ private_class_method :valid_name?, :valid_prefix?, :split_pair, :removal?, :removal_key, :duplicate!,
124
+ :reject_overlap, :as_usage_error
125
+ end
126
+ end