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,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
|
data/lib/slipway/git.rb
ADDED
|
@@ -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
|