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,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