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,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative '../views'
|
|
5
|
+
|
|
6
|
+
module Slipway
|
|
7
|
+
module Commands
|
|
8
|
+
class Get < Base
|
|
9
|
+
DESCRIPTION = "Display one or many resources.\n\n" \
|
|
10
|
+
'Prints a table of the most important information about the specified resources. ' \
|
|
11
|
+
'You can filter the list using a label selector and the --selector flag, or a field ' \
|
|
12
|
+
'selector and the --field-selector flag. Projects are listed in the current group unless ' \
|
|
13
|
+
"you pass --all-groups.\n\n" \
|
|
14
|
+
'Use -o wide to add the path, the head commit, the age of the last commit and the drift of ' \
|
|
15
|
+
'each project: the ways its repository differs from its manifest and what keeps sync from ' \
|
|
16
|
+
"converging it. Use -o json or -o yaml for the full object with its status.\n\n" \
|
|
17
|
+
"#{Options::TYPES_SENTENCE}".freeze
|
|
18
|
+
USAGE = '(TYPE [NAME...] | TYPE/NAME...)'
|
|
19
|
+
COLUMNS = {
|
|
20
|
+
'GROUP' => 'The group of the project.',
|
|
21
|
+
'NAME' => 'The name of the resource.',
|
|
22
|
+
'BRANCH' => "The checked-out branch, or #{Views::Project::DETACHED} when HEAD points at a commit.",
|
|
23
|
+
'STATUS' => 'One word for the state of the repository, from the list below.',
|
|
24
|
+
'FETCHED' => 'Time since the repository was last fetched, by slipway or by git itself and from any of its ' \
|
|
25
|
+
"worktrees. #{Views::Project::NEVER} means git answered and no fetch is on record, which " \
|
|
26
|
+
'includes a last fetch that failed.',
|
|
27
|
+
'AGE' => 'Time since the resource was registered, in kubectl\'s units (3s, 4m12s, 11h, 2y319d).',
|
|
28
|
+
'PATH' => 'The registered path, as the manifest writes it.',
|
|
29
|
+
'HEAD' => 'The abbreviated id of the checked-out commit.',
|
|
30
|
+
'LAST-COMMIT' => 'The age of the checked-out commit.',
|
|
31
|
+
'DRIFT' => 'The ways the repository differs from its manifest and the blocker that keeps sync from ' \
|
|
32
|
+
'fast-forwarding it, as slipway diff names them.',
|
|
33
|
+
'PROJECTS' => 'The number of projects in the group.',
|
|
34
|
+
'DESCRIPTION' => 'The description of the group.',
|
|
35
|
+
'LABELS' => 'The labels, as key=value pairs.'
|
|
36
|
+
}.freeze
|
|
37
|
+
STATUS_WORDS = CLI::Glossary.new(title: 'Status Words',
|
|
38
|
+
intro: 'STATUS is the first of these words that holds, in this order.',
|
|
39
|
+
entries: State::MEANINGS)
|
|
40
|
+
GLOSSARIES = [
|
|
41
|
+
CLI::Glossary.new(
|
|
42
|
+
title: 'Columns',
|
|
43
|
+
intro: 'Projects show NAME, BRANCH, STATUS, FETCHED and AGE; --all-groups adds GROUP in front, -o wide ' \
|
|
44
|
+
'adds PATH, HEAD, LAST-COMMIT and DRIFT, and --show-labels adds LABELS. Groups show NAME, ' \
|
|
45
|
+
'PROJECTS and AGE, and -o wide adds DESCRIPTION. A cell slipway has no value for reads ' \
|
|
46
|
+
"#{Output::Table::NONE}, as every cell from git does when git cannot read the repository.",
|
|
47
|
+
entries: COLUMNS
|
|
48
|
+
),
|
|
49
|
+
STATUS_WORDS
|
|
50
|
+
].freeze
|
|
51
|
+
|
|
52
|
+
def self.command(factory)
|
|
53
|
+
CLI::Command.new(
|
|
54
|
+
name: 'get', summary: 'Display one or many resources', section: 'Basic Commands',
|
|
55
|
+
description: DESCRIPTION, examples:, usage: USAGE, glossaries: GLOSSARIES,
|
|
56
|
+
positionals: [Options::TYPE, Options.name_positional(factory, variadic: true, required: false)],
|
|
57
|
+
options: [Options::OUTPUT, Options::SELECTOR, Options::FIELD_SELECTOR, Options::ALL_GROUPS,
|
|
58
|
+
Options::NO_HEADERS, Options::SHOW_LABELS],
|
|
59
|
+
handler: new(factory)
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.examples
|
|
64
|
+
[
|
|
65
|
+
CLI::Example.new(comment: 'List all projects in the current group', command: 'get projects'),
|
|
66
|
+
CLI::Example.new(comment: 'List all projects in every group, with the path and last commit',
|
|
67
|
+
command: 'get projects -A -o wide'),
|
|
68
|
+
CLI::Example.new(comment: 'List a single project in YAML output format',
|
|
69
|
+
command: 'get project hldr -o yaml'),
|
|
70
|
+
CLI::Example.new(comment: 'List the projects labeled lang=rust', command: 'get projects -l lang=rust'),
|
|
71
|
+
CLI::Example.new(comment: 'List the projects in every group that are not clean',
|
|
72
|
+
command: 'get projects -A --field-selector status.state!=Clean'),
|
|
73
|
+
CLI::Example.new(comment: 'List the projects no fetch has reached',
|
|
74
|
+
command: 'get projects --field-selector status.lastFetch=never'),
|
|
75
|
+
CLI::Example.new(comment: 'List every group', command: 'get groups')
|
|
76
|
+
]
|
|
77
|
+
end
|
|
78
|
+
private_class_method :examples
|
|
79
|
+
|
|
80
|
+
def kinds = Resources::KINDS
|
|
81
|
+
|
|
82
|
+
def run(runtime, context, args, opts)
|
|
83
|
+
scope = scope(runtime, context, opts)
|
|
84
|
+
kind, names = scope.targets(args)
|
|
85
|
+
fields = scope.field_selector(kind, names)
|
|
86
|
+
scope.select(kind, names) do |resources|
|
|
87
|
+
printer = Printer.new(runtime, context, opts, group_column: scope.all_groups?)
|
|
88
|
+
items = printer.items(kind, resources, fields)
|
|
89
|
+
next scope.report_none(kind) if items.empty?
|
|
90
|
+
|
|
91
|
+
printer.print(kind, items, single: names.size == 1)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
class Printer
|
|
96
|
+
def initialize(runtime, context, opts, group_column:)
|
|
97
|
+
@runtime = runtime
|
|
98
|
+
@context = context
|
|
99
|
+
@opts = opts
|
|
100
|
+
@group_column = group_column
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Projects come back examined for every format but name, the one that shows nothing
|
|
104
|
+
# git answers. A field selector reads what git answered, so with one even -o name
|
|
105
|
+
# examines them.
|
|
106
|
+
def items(kind, resources, fields)
|
|
107
|
+
return fields.filter(resources) { group_object(it) } unless kind.namespaced?
|
|
108
|
+
return resources if name? && fields.empty?
|
|
109
|
+
|
|
110
|
+
inspections = fields.filter(examine(resources)) { Views::Project.object(it) }
|
|
111
|
+
name? ? inspections.map(&:project) : inspections
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def print(kind, items, single:)
|
|
115
|
+
case @opts[:output]
|
|
116
|
+
when Output::NAME then items.each { @context.puts("#{kind.singular}/#{it.name}") }
|
|
117
|
+
when *Output::Serializer::STRUCTURED then objects(kind, items, single:)
|
|
118
|
+
else table(kind, items)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def objects(kind, items, single:)
|
|
125
|
+
objects = items.map { kind.namespaced? ? Views::Project.object(it) : group_object(it) }
|
|
126
|
+
@context.print(Output::Serializer.render(@opts[:output], objects, single:))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def table(kind, items)
|
|
130
|
+
headers, rows, roles, offset = kind.namespaced? ? project_table(items) : group_table(items)
|
|
131
|
+
Output::Table.new(@context, headers:, show_headers: !@opts[:no_headers], roles:, color_offset: offset)
|
|
132
|
+
.print(rows)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The GROUP column -A prepends is left out of the color cycle, so the other columns
|
|
136
|
+
# keep the colors they have without -A.
|
|
137
|
+
def project_table(inspections)
|
|
138
|
+
columns = { wide: wide?, group: @group_column, labels: labels? }
|
|
139
|
+
now = @runtime.clock.call
|
|
140
|
+
[Views::Project.headers(**columns), inspections.map { Views::Project.row(it, now:, **columns) },
|
|
141
|
+
Views::Project::ROLES, @group_column ? 1 : 0]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def group_table(groups)
|
|
145
|
+
columns = { wide: wide?, labels: labels? }
|
|
146
|
+
now = @runtime.clock.call
|
|
147
|
+
[Views::Group.headers(**columns), groups.map { Views::Group.row(it, count: count(it), now:, **columns) },
|
|
148
|
+
nil, 0]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def examine(resources) = Base.examine(@runtime, @context, resources)
|
|
152
|
+
|
|
153
|
+
def group_object(group) = Views::Group.object(group, count: count(group))
|
|
154
|
+
|
|
155
|
+
def count(group) = @runtime.store.project_count(group.name)
|
|
156
|
+
|
|
157
|
+
def wide? = @opts[:output] == Output::WIDE
|
|
158
|
+
|
|
159
|
+
def labels? = @opts[:show_labels] == true
|
|
160
|
+
|
|
161
|
+
def name? = @opts[:output] == Output::NAME
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private_constant :Printer
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative '../labels'
|
|
5
|
+
|
|
6
|
+
module Slipway
|
|
7
|
+
module Commands
|
|
8
|
+
class Label < Base
|
|
9
|
+
DESCRIPTION = <<~TEXT.chomp
|
|
10
|
+
Update the labels on a resource.
|
|
11
|
+
|
|
12
|
+
* A label key and value must begin with a letter or number, and may contain letters, numbers, hyphens, dots, and underscores, up to 63 characters each.
|
|
13
|
+
* Optionally, the key can begin with a DNS subdomain prefix and a single '/', like example.com/my-app.
|
|
14
|
+
* If --overwrite is true, then existing labels can be overwritten, otherwise attempting to overwrite a label will result in an error.
|
|
15
|
+
* KEY- removes the label KEY; removing a label that is not set changes nothing. A change that leaves the labels as they were prints not labeled.
|
|
16
|
+
|
|
17
|
+
#{Options::TYPES_SENTENCE}
|
|
18
|
+
TEXT
|
|
19
|
+
NO_CHANGES = 'at least one label update is required'
|
|
20
|
+
LABELED = 'labeled'
|
|
21
|
+
UNLABELED = 'unlabeled'
|
|
22
|
+
NOT_LABELED = 'not labeled'
|
|
23
|
+
|
|
24
|
+
OVERWRITE = CLI::Option.new(long: 'overwrite',
|
|
25
|
+
description: 'If true, allow labels to be overwritten, otherwise reject label ' \
|
|
26
|
+
'updates that overwrite existing labels.')
|
|
27
|
+
LIST = CLI::Option.new(long: 'list',
|
|
28
|
+
description: 'If true, display the labels for a given resource instead of writing them.')
|
|
29
|
+
CHANGE = CLI::Positional.new(name: 'KEY=VALUE|KEY-', required: false, variadic: true)
|
|
30
|
+
USAGE = "(TYPE NAME | TYPE/NAME) #{CHANGE.usage}".freeze
|
|
31
|
+
|
|
32
|
+
Change = Data.define(:sets, :removals) do
|
|
33
|
+
# A malformed, repeated or contradictory word is a usage error.
|
|
34
|
+
def self.parse(words) = new(*Labels.parse_changes(words))
|
|
35
|
+
|
|
36
|
+
def empty? = sets.empty? && removals.empty?
|
|
37
|
+
|
|
38
|
+
# Refuses a silent overwrite with kubectl's error message.
|
|
39
|
+
def apply(labels, overwrite:)
|
|
40
|
+
sets.each do |key, value|
|
|
41
|
+
next if overwrite || !labels.key?(key) || labels[key] == value
|
|
42
|
+
|
|
43
|
+
raise Error, "'#{key}' already has a value (#{labels[key]}), and --overwrite is false"
|
|
44
|
+
end
|
|
45
|
+
labels.merge(sets).except(*removals)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def outcome(labels)
|
|
49
|
+
return [LABELED, :label_labeled] if sets.any? { |key, value| labels[key] != value }
|
|
50
|
+
return [UNLABELED, :label_unlabeled] if removals.any? { labels.key?(it) }
|
|
51
|
+
|
|
52
|
+
[NOT_LABELED, :label_not_labeled]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.command(factory)
|
|
57
|
+
CLI::Command.new(
|
|
58
|
+
name: 'label', summary: 'Update the labels on a resource', section: 'Basic Commands',
|
|
59
|
+
description: DESCRIPTION, examples:, usage: USAGE,
|
|
60
|
+
positionals: [Options::TYPE, Options.name_positional(factory, variadic: false, required: false), CHANGE],
|
|
61
|
+
options: [OVERWRITE, LIST, Options::DRY_RUN],
|
|
62
|
+
handler: new(factory)
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.examples
|
|
67
|
+
[
|
|
68
|
+
CLI::Example.new(comment: "Update project 'hldr' with the label 'lang' and the value 'rust'",
|
|
69
|
+
command: 'label project hldr lang=rust'),
|
|
70
|
+
CLI::Example.new(comment: "Update project 'hldr' with the label 'lang' and the value 'go', overwriting " \
|
|
71
|
+
'any existing value', command: 'label project hldr lang=go --overwrite'),
|
|
72
|
+
CLI::Example.new(comment: "Remove the label 'tier' from project 'hldr'", command: 'label project hldr tier-'),
|
|
73
|
+
CLI::Example.new(comment: "List the labels of group 'work'", command: 'label group work --list')
|
|
74
|
+
]
|
|
75
|
+
end
|
|
76
|
+
private_class_method :examples
|
|
77
|
+
|
|
78
|
+
def kinds = Resources::KINDS
|
|
79
|
+
|
|
80
|
+
def run(runtime, context, args, opts)
|
|
81
|
+
scope = scope(runtime, context, opts)
|
|
82
|
+
kind, name, words = split(scope, args)
|
|
83
|
+
change = Change.parse(words)
|
|
84
|
+
raise CLI::UsageError, NO_CHANGES if change.empty? && opts[:list] != true
|
|
85
|
+
|
|
86
|
+
resource = runtime.store.find(kind, name, group: kind.namespaced? ? scope.group : nil)
|
|
87
|
+
labels = change.apply(resource.labels, overwrite: opts[:overwrite] == true)
|
|
88
|
+
return list(context, labels) if opts[:list] == true
|
|
89
|
+
|
|
90
|
+
write(runtime.store, context, kind, resource.with_labels(labels), change.outcome(resource.labels),
|
|
91
|
+
dry_run: opts[:dry_run] == true)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def split(scope, args)
|
|
97
|
+
type, *rest = args
|
|
98
|
+
return [*scope.target([type]), rest] if type.include?('/')
|
|
99
|
+
|
|
100
|
+
[*scope.target([type, *rest.first(1)]), rest.drop(1)]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def write(store, context, kind, resource, (word, role), dry_run:)
|
|
104
|
+
store.save(resource) unless dry_run || word == NOT_LABELED
|
|
105
|
+
result_line(context, kind, resource.name, word, role, dry_run:)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# `--list` computes the labels without writing them, as kubectl does.
|
|
109
|
+
def list(context, labels)
|
|
110
|
+
labels.sort.each { |key, value| context.puts("#{key}=#{value}") }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../settings'
|
|
4
|
+
require_relative '../git'
|
|
5
|
+
|
|
6
|
+
module Slipway
|
|
7
|
+
module Commands
|
|
8
|
+
# The parts of slipway(1) that belong to no single verb. CLI::Manpage renders them, and
|
|
9
|
+
# bin/generate-man hands them over, because the files and variables they name are slipway's.
|
|
10
|
+
module Manual
|
|
11
|
+
EXIT_STATUSES = {
|
|
12
|
+
'0' => 'Success.',
|
|
13
|
+
'1' => 'Runtime error, such as a missing resource or an unreadable manifest.',
|
|
14
|
+
'2' => 'Usage error: unknown command, unknown flag or invalid argument.',
|
|
15
|
+
'130' => 'Interrupted by SIGINT.'
|
|
16
|
+
}.freeze
|
|
17
|
+
# A setting's variable points at the CONFIGURATION entry that describes its value, so the
|
|
18
|
+
# value is described once.
|
|
19
|
+
def self.setting(key, rest = '')
|
|
20
|
+
variable = Settings::ALL.find { it.key == key }.variable
|
|
21
|
+
[variable, "Outranks the #{key} key (see CONFIGURATION)#{rest}."]
|
|
22
|
+
end
|
|
23
|
+
private_class_method :setting
|
|
24
|
+
|
|
25
|
+
# Every variable the code reads but HOME and PATH, which test/unit/seams_test.rb checks.
|
|
26
|
+
ENVIRONMENT = {
|
|
27
|
+
'SLIPWAY_CONFIG' => 'Path of the configuration file; overridden by --config.',
|
|
28
|
+
'SLIPWAY_DATA_HOME' => 'Directory holding the registry: group and project manifests.',
|
|
29
|
+
**[setting('color', '; --color outranks both'), setting('theme'), setting('editor', ', VISUAL and EDITOR'),
|
|
30
|
+
setting('group', '; --group outranks both'), setting('networkTimeout'), setting('parallel'),
|
|
31
|
+
setting('protocols', '; the names are separated by colons, as in ssh:https')].to_h,
|
|
32
|
+
'SLIPWAY_DEBUG' => 'When non-empty, unexpected errors also print their class and backtrace.',
|
|
33
|
+
'NO_COLOR' => 'When non-empty, disables color in auto mode.',
|
|
34
|
+
'FORCE_COLOR' => 'When non-empty, enables color in auto mode even without a terminal.',
|
|
35
|
+
'CLICOLOR_FORCE' => 'Same as FORCE_COLOR.',
|
|
36
|
+
'VISUAL' => 'Editor used by edit when SLIPWAY_EDITOR and the editor configuration key are unset.',
|
|
37
|
+
'EDITOR' => 'Editor used by edit when VISUAL is unset as well.',
|
|
38
|
+
'XDG_CONFIG_HOME' => 'Base of the configuration directory (default ~/.config).',
|
|
39
|
+
'XDG_DATA_HOME' => 'Base of the data directory (default ~/.local/share).',
|
|
40
|
+
'TERM' => 'When dumb, disables color in auto mode.',
|
|
41
|
+
'MANPAGER' => 'When non-empty, slipway man leaves the pager palette alone.',
|
|
42
|
+
'MANROFFOPT' => 'Same as MANPAGER.',
|
|
43
|
+
'LESS_TERMCAP_md' => 'Same as MANPAGER.',
|
|
44
|
+
'GROFF_NO_SGR' => 'Same as MANPAGER.'
|
|
45
|
+
}.freeze
|
|
46
|
+
# The variables slipway clears for git instead of reading them.
|
|
47
|
+
CLEARED = {
|
|
48
|
+
Git::Runner::ENVIRONMENT.filter_map { |name, value| name if value.nil? && name.start_with?('GIT_') }
|
|
49
|
+
.join(', ') =>
|
|
50
|
+
'Ignored: every git command slipway runs starts without them, so an inherited value cannot point git at ' \
|
|
51
|
+
'another repository.'
|
|
52
|
+
}.freeze
|
|
53
|
+
FILES = {
|
|
54
|
+
'$XDG_CONFIG_HOME/slipway/config.yaml' =>
|
|
55
|
+
'Configuration file; see CONFIGURATION. Also set by --config or SLIPWAY_CONFIG.',
|
|
56
|
+
'$XDG_DATA_HOME/slipway/' =>
|
|
57
|
+
'Registry data: groups/NAME.yaml and projects/GROUP/NAME.yaml. Also set by SLIPWAY_DATA_HOME.'
|
|
58
|
+
}.freeze
|
|
59
|
+
|
|
60
|
+
# The keyword arguments CLI::Manpage takes for slipway's own sections.
|
|
61
|
+
def self.sections
|
|
62
|
+
{ environment: ENVIRONMENT.merge(CLEARED), files: FILES, configuration: Settings::DOCUMENTATION,
|
|
63
|
+
exit_statuses: EXIT_STATUSES }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../cli'
|
|
4
|
+
require_relative '../resources'
|
|
5
|
+
require_relative '../output'
|
|
6
|
+
require_relative '../views'
|
|
7
|
+
|
|
8
|
+
module Slipway
|
|
9
|
+
module Commands
|
|
10
|
+
module Options
|
|
11
|
+
OUTPUT = CLI::Option.new(long: 'output', short: 'o', argument: 'FORMAT', enum: Output::FORMATS,
|
|
12
|
+
default: Output::TABLE, description: 'Output format.')
|
|
13
|
+
SELECTOR = CLI::Option.new(long: 'selector', short: 'l', argument: 'EXPR',
|
|
14
|
+
description: "Selector (label query) to filter on, supports '=', '==', '!=', 'in', " \
|
|
15
|
+
"'notin' (e.g. -l key1=value1,key2=value2,key3 in (value3)). " \
|
|
16
|
+
'Matching objects must satisfy all of the specified label constraints.')
|
|
17
|
+
FIELD_SELECTOR = CLI::Option.new(long: 'field-selector', argument: 'EXPR',
|
|
18
|
+
description: "Selector (field query) to filter on, supports '=', '==', and " \
|
|
19
|
+
"'!=' (e.g. --field-selector key1=value1,key2=value2). Projects " \
|
|
20
|
+
"support #{Views::Project::FIELDS.keys.join(', ')}; groups " \
|
|
21
|
+
"support #{Views::Group::FIELDS.keys.join(', ')}. Values " \
|
|
22
|
+
'compare exactly, case included, with the object -o json ' \
|
|
23
|
+
'prints: spec.path as registered, status.lastFetch as an RFC ' \
|
|
24
|
+
'3339 time. A field the object leaves out compares as the ' \
|
|
25
|
+
'empty value, except status.lastFetch, which compares as never. ' \
|
|
26
|
+
'A backslash escapes a backslash, a comma or an equals sign in ' \
|
|
27
|
+
'a value.')
|
|
28
|
+
ALL_GROUPS = CLI::Option.new(long: 'all-groups', short: 'A',
|
|
29
|
+
description: 'If present, list the requested object(s) across all groups. ' \
|
|
30
|
+
'The group in the current configuration is ignored even if ' \
|
|
31
|
+
'specified with --group.')
|
|
32
|
+
NO_HEADERS = CLI::Option.new(long: 'no-headers',
|
|
33
|
+
description: "When using the default output format, don't print headers.")
|
|
34
|
+
SHOW_LABELS = CLI::Option.new(long: 'show-labels',
|
|
35
|
+
description: 'When printing, show all labels as the last column.')
|
|
36
|
+
DRY_RUN = CLI::Option.new(long: 'dry-run', description: 'Print what would change and write nothing.')
|
|
37
|
+
|
|
38
|
+
TYPE_DESCRIPTIONS = {
|
|
39
|
+
'projects' => 'Registered git repositories',
|
|
40
|
+
'groups' => 'Namespaces that hold projects'
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
TYPES = Resources::KINDS.map { "#{it.plural} (#{[it.singular, *it.aliases].join(', ')})" }.join(' and ')
|
|
44
|
+
TYPES_SENTENCE = "Resource types: #{TYPES}. Type words are case-insensitive.".freeze
|
|
45
|
+
|
|
46
|
+
# Unknown words are reported by Resources.resolve at run time.
|
|
47
|
+
TYPE = CLI::Positional.new(name: 'TYPE', completer: ->(_given, _current) { TYPE_DESCRIPTIONS })
|
|
48
|
+
|
|
49
|
+
def self.name_positional(factory, variadic: true, required: false)
|
|
50
|
+
CLI::Positional.new(name: 'NAME', variadic:, required:,
|
|
51
|
+
completer: ->(given, _current) { names(factory, given) })
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# No TYPE word comes first, so completion always offers project names.
|
|
55
|
+
def self.project_positional(factory, variadic: true, required: false)
|
|
56
|
+
CLI::Positional.new(name: 'NAME', variadic:, required:,
|
|
57
|
+
completer: ->(_given, _current) { names(factory, [Resources::PROJECTS.plural]) })
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.group_completer(factory) = ->(_given, _current) { names(factory, [Resources::GROUPS.plural]) }
|
|
61
|
+
|
|
62
|
+
# Runs during shell completion, with the process environment and no flags: any failure
|
|
63
|
+
# means no candidates rather than an error in the shell.
|
|
64
|
+
def self.names(factory, given)
|
|
65
|
+
runtime = factory.call(CLI::Context.system, {})
|
|
66
|
+
runtime.store.names(Resources.resolve(given.fetch(0)), group: nil)
|
|
67
|
+
rescue StandardError
|
|
68
|
+
[]
|
|
69
|
+
end
|
|
70
|
+
private_class_method :names
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative '../git'
|
|
5
|
+
require_relative '../output'
|
|
6
|
+
require_relative '../pool'
|
|
7
|
+
|
|
8
|
+
module Slipway
|
|
9
|
+
module Commands
|
|
10
|
+
# The lines of a verb that acts on each selected repository: one result per project, its
|
|
11
|
+
# details below it, and a count of the results on stderr once more than one project ran.
|
|
12
|
+
class Results
|
|
13
|
+
INDENT = ' '
|
|
14
|
+
DRY_RUN = '(dry run)'
|
|
15
|
+
|
|
16
|
+
# `roles` maps each result word to its theme role, in the order the count lists them.
|
|
17
|
+
def initialize(context, roles, dry_run:)
|
|
18
|
+
@context = context
|
|
19
|
+
@roles = roles
|
|
20
|
+
@dry_run = dry_run
|
|
21
|
+
@tally = Hash.new(0)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Runs `work` for each project on a pool of `workers` and prints each outcome, or what the
|
|
25
|
+
# block turns it into on the calling thread, in the order the projects are listed.
|
|
26
|
+
# Ruby buffers a stdout that is not a terminal: a pipe would see the lines only at exit,
|
|
27
|
+
# after the count on stderr, and since Ruby flushes stdout before it spawns, a line a
|
|
28
|
+
# closed pipe refused would stay in the buffer and fail every later git with EPIPE.
|
|
29
|
+
def stream(projects, workers:, work:)
|
|
30
|
+
@context.unbuffer
|
|
31
|
+
Pool.new(workers:).each_ordered(projects, work) { report(block_given? ? yield(it) : it) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def any?(*words) = words.any? { @tally.key?(it) }
|
|
35
|
+
|
|
36
|
+
def summarize
|
|
37
|
+
count = @tally.values.sum
|
|
38
|
+
return if count < 2
|
|
39
|
+
|
|
40
|
+
counts = @roles.keys.filter_map { "#{@tally[it]} #{it}" if @tally.key?(it) }
|
|
41
|
+
line = "#{count} projects: #{counts.join(', ')}"
|
|
42
|
+
line += " #{DRY_RUN}" if @dry_run
|
|
43
|
+
@context.warn(@context.paint_err(:muted, line))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Ref names and messages come from git and the remote, so they are redacted and made plain.
|
|
47
|
+
def report(outcome)
|
|
48
|
+
word = outcome.word
|
|
49
|
+
@tally[word] += 1
|
|
50
|
+
line = Base.result_text(@context, Resources::PROJECTS, outcome.project.name, word, @roles.fetch(word),
|
|
51
|
+
reason: outcome.reason, dry_run: @dry_run)
|
|
52
|
+
details = outcome.details.map { INDENT + @context.paint(:muted, Output.plain(Git::Url.redact(it))) }
|
|
53
|
+
@context.puts(line, *details)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative 'rollout_undo'
|
|
5
|
+
require_relative 'rollout_spec'
|
|
6
|
+
require_relative '../paths'
|
|
7
|
+
require_relative '../rollout_history'
|
|
8
|
+
|
|
9
|
+
module Slipway
|
|
10
|
+
module Commands
|
|
11
|
+
module Rollout
|
|
12
|
+
DESCRIPTION = "Manage the rollout of a project.\n\n" \
|
|
13
|
+
'Slipway records a revision each time it moves the checked-out branch of a project: every ' \
|
|
14
|
+
'fast-forward of slipway sync and every slipway rollout undo leaves an entry in the ' \
|
|
15
|
+
"branch's reflog, and the commit the branch stood at before such a move is a revision too. " \
|
|
16
|
+
'rollout history lists the revisions, rollout undo returns the branch to one of them and ' \
|
|
17
|
+
'holds the project there with spec.revision, and rollout unpin lets sync follow the upstream ' \
|
|
18
|
+
'again. rollout pause keeps fetch and sync away from a project until rollout resume.'
|
|
19
|
+
SINGLE_USAGE = '(NAME | project/NAME)'
|
|
20
|
+
MANY_USAGE = '(NAME... | project/NAME...)'
|
|
21
|
+
|
|
22
|
+
def self.command(factory)
|
|
23
|
+
CLI::Command.new(
|
|
24
|
+
name: 'rollout', summary: 'Manage the rollout of a project', section: 'Repository Commands',
|
|
25
|
+
description: DESCRIPTION,
|
|
26
|
+
subcommands: [History, Undo, Unpin, Pause, Resume].map { it.command(factory) }
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class History < Base
|
|
31
|
+
DESCRIPTION = "View the rollout history of a project.\n\n" \
|
|
32
|
+
'Lists the revisions of the checked-out branch, oldest first: each commit slipway moved the ' \
|
|
33
|
+
'branch to, read from the entries of the branch\'s reflog whose subject starts with ' \
|
|
34
|
+
'"slipway ", and the commit the branch stood at before such a move. REVISION numbers them ' \
|
|
35
|
+
'from the oldest entry git still keeps, COMMIT is the commit, DATE the time the branch ' \
|
|
36
|
+
'moved there, CHANGE-CAUSE the move slipway made, and PINNED marks the revision spec.revision ' \
|
|
37
|
+
"holds the project at. Nothing is fetched or written.\n\n" \
|
|
38
|
+
'The history lasts as long as git keeps the reflog (gc.reflogExpire, 90 days by default; ' \
|
|
39
|
+
'slipway never runs gc), and git keeps none under core.logAllRefUpdates=false. A project ' \
|
|
40
|
+
'without history prints a notice on stderr and exits with status 0.'
|
|
41
|
+
HEADERS = %w[REVISION COMMIT DATE CHANGE-CAUSE PINNED].freeze
|
|
42
|
+
EMPTY = 'No rollout history found for %s.'
|
|
43
|
+
DETACHED = '%s: HEAD is detached at %s; rollout history reads the reflog of the checked-out branch'
|
|
44
|
+
ABBREV = Git::Porcelain::ABBREVIATION
|
|
45
|
+
|
|
46
|
+
def self.command(factory)
|
|
47
|
+
CLI::Command.new(
|
|
48
|
+
name: 'history', summary: 'View rollout history', description: DESCRIPTION, usage: SINGLE_USAGE,
|
|
49
|
+
examples: [
|
|
50
|
+
CLI::Example.new(comment: 'View the rollout history of project hldr', command: 'rollout history hldr'),
|
|
51
|
+
CLI::Example.new(comment: 'View the rollout history of project api in the work group',
|
|
52
|
+
command: 'rollout history project/api -n work')
|
|
53
|
+
],
|
|
54
|
+
positionals: [Options.project_positional(factory, variadic: false, required: true)],
|
|
55
|
+
handler: new(factory)
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def kinds = [Resources::PROJECTS]
|
|
60
|
+
|
|
61
|
+
def run(runtime, context, args, opts)
|
|
62
|
+
scope = scope(runtime, context, opts)
|
|
63
|
+
names = scope.project_targets(args, verb: 'view the rollout history of')
|
|
64
|
+
scope.select(Resources::PROJECTS, names) { |projects| projects.each { show(runtime, context, it) } }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def show(runtime, context, project)
|
|
70
|
+
name = "#{Resources::PROJECTS.singular}/#{project.name}"
|
|
71
|
+
path = Paths.expand(project.path, home: runtime.paths.home)
|
|
72
|
+
history = RolloutHistory.new(entries(runtime, project, name, path))
|
|
73
|
+
return context.warn(context.paint_err(:muted, format(EMPTY, name))) if history.empty?
|
|
74
|
+
|
|
75
|
+
pinned = history.pinned(project.revision)
|
|
76
|
+
rows = history.revisions.map { row(runtime, path, history, it, pinned) }
|
|
77
|
+
Output::Table.new(context, headers: HEADERS).print(rows)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def row(runtime, path, history, revision, pinned)
|
|
81
|
+
[revision.number, revision.sha[0, ABBREV], Resources.timestamp(revision.time),
|
|
82
|
+
cause(runtime, path, history, revision), revision.equal?(pinned)]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A branch without commits has no reflog yet, and so no history.
|
|
86
|
+
def entries(runtime, project, name, path)
|
|
87
|
+
inspection = runtime.inspector.examine(project)
|
|
88
|
+
raise inspection.error if inspection.error
|
|
89
|
+
|
|
90
|
+
status = inspection.status
|
|
91
|
+
raise Error, format(DETACHED, name, status.head) if status.detached?
|
|
92
|
+
|
|
93
|
+
runtime.git.reflog(path, status.branch)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def cause(runtime, path, history, revision)
|
|
97
|
+
case revision.action
|
|
98
|
+
when nil then nil
|
|
99
|
+
when RolloutHistory::SYNC then "sync: fast-forward#{gained(runtime, path, revision)}"
|
|
100
|
+
when RolloutHistory::UNDO
|
|
101
|
+
undone = history.undone_to(revision)
|
|
102
|
+
undone ? "rollout undo to revision #{undone.number}" : RolloutHistory::UNDO
|
|
103
|
+
else revision.action
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def gained(runtime, path, revision)
|
|
108
|
+
count = revision.from && runtime.git.commits_between(path, revision.from, revision.sha)
|
|
109
|
+
count ? " #{count} commit#{'s' unless count == 1}" : ''
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|