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,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Slipway
6
+ module Commands
7
+ module Rollout
8
+ # Writes one spec field of each named project and prints what changed. No git command runs:
9
+ # the next fetch or sync reads the field.
10
+ class SpecChange < Base
11
+ def kinds = [Resources::PROJECTS]
12
+
13
+ def run(runtime, context, args, opts)
14
+ scope = scope(runtime, context, opts)
15
+ names = scope.project_targets(args, verb: self.class::VERB)
16
+ scope.select(Resources::PROJECTS, names) { |projects| projects.each { write(runtime, context, it) } }
17
+ end
18
+
19
+ private
20
+
21
+ def write(runtime, context, project)
22
+ changed = change(project)
23
+ runtime.store.save(changed) if changed
24
+ word, role = changed ? [self.class::DONE, :result_changed] : [self.class::NOTHING, :result_unchanged]
25
+ result_line(context, Resources::PROJECTS, project.name, word, role)
26
+ end
27
+ end
28
+
29
+ class Unpin < SpecChange
30
+ DESCRIPTION = "Stop holding projects at a revision.\n\n" \
31
+ 'Removes spec.revision from the manifest of each named project, so the next slipway sync ' \
32
+ 'fast-forwards its branch onto its upstream again. The repository is left as it is. A ' \
33
+ 'project without spec.revision prints not pinned.'
34
+ VERB = 'unpin'
35
+ DONE = 'unpinned'
36
+ NOTHING = 'not pinned'
37
+
38
+ def self.command(factory)
39
+ CLI::Command.new(
40
+ name: 'unpin', summary: 'Stop holding a project at a revision', description: DESCRIPTION,
41
+ usage: MANY_USAGE,
42
+ examples: [CLI::Example.new(comment: 'Let project hldr follow its upstream again',
43
+ command: 'rollout unpin hldr')],
44
+ positionals: [Options.project_positional(factory, required: true)], handler: new(factory)
45
+ )
46
+ end
47
+
48
+ private
49
+
50
+ def change(project) = project.revision && project.with(revision: nil)
51
+ end
52
+
53
+ class Pause < SpecChange
54
+ DESCRIPTION = "Mark the provided projects as paused.\n\n" \
55
+ 'Sets spec.paused in the manifest of each named project. Paused projects are left alone by ' \
56
+ 'slipway fetch and slipway sync, which run no git command in them; slipway rollout undo still ' \
57
+ 'acts on them. Use slipway rollout resume to resume a paused project.'
58
+ VERB = 'pause'
59
+ DONE = 'paused'
60
+ NOTHING = 'already paused'
61
+
62
+ def self.command(factory)
63
+ CLI::Command.new(
64
+ name: 'pause', summary: 'Mark the provided project as paused', description: DESCRIPTION,
65
+ usage: MANY_USAGE,
66
+ examples: [CLI::Example.new(comment: 'Keep fetch and sync away from project dots',
67
+ command: 'rollout pause dots')],
68
+ positionals: [Options.project_positional(factory, required: true)], handler: new(factory)
69
+ )
70
+ end
71
+
72
+ private
73
+
74
+ def change(project) = project.paused ? nil : project.with(paused: true)
75
+ end
76
+
77
+ class Resume < SpecChange
78
+ DESCRIPTION = "Resume paused projects.\n\n" \
79
+ 'Removes spec.paused from the manifest of each named project, so slipway fetch and slipway ' \
80
+ 'sync act on it again. A project that is not paused prints not paused.'
81
+ VERB = 'resume'
82
+ DONE = 'resumed'
83
+ NOTHING = 'not paused'
84
+
85
+ def self.command(factory)
86
+ CLI::Command.new(
87
+ name: 'resume', summary: 'Resume a paused project', description: DESCRIPTION, usage: MANY_USAGE,
88
+ examples: [CLI::Example.new(comment: 'Resume the paused project dots', command: 'rollout resume dots')],
89
+ positionals: [Options.project_positional(factory, required: true)], handler: new(factory)
90
+ )
91
+ end
92
+
93
+ private
94
+
95
+ def change(project) = project.paused ? project.with(paused: false) : nil
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'manual'
5
+ require_relative 'results'
6
+ require_relative '../outcome'
7
+ require_relative '../rollback'
8
+
9
+ module Slipway
10
+ module Commands
11
+ module Rollout
12
+ class Undo < Base
13
+ # Raised once the line has printed: it already says why, so this adds only exit status 1.
14
+ class Failed < Error
15
+ def initialize = super(problems: [])
16
+ end
17
+
18
+ DESCRIPTION = "Roll back a project to a previous revision.\n\n" \
19
+ 'Moves the checked-out branch to the revision before the current one, or to the one ' \
20
+ '--to-revision names from slipway rollout history, and then writes that commit to ' \
21
+ 'spec.revision, so sync holds the project there until slipway rollout unpin. The branch moves ' \
22
+ 'back with git reset --keep, only when its upstream holds every commit the move drops, and ' \
23
+ 'forward, to undo an undo, with git merge --ff-only. Untracked files and unstaged changes to ' \
24
+ 'files the move leaves alone are kept; git refuses a move that would overwrite a local change ' \
25
+ 'or an untracked file, and slipway refuses one that would overwrite an ignored file. A move ' \
26
+ 'back skips a project with staged changes, and a move forward one with staged or unstaged ' \
27
+ "changes.\n\n" \
28
+ 'A project whose HEAD is detached, whose branch has no commits or no upstream that exists, ' \
29
+ 'that is in the middle of a merge, rebase or other operation, or whose branch has commits its ' \
30
+ 'upstream lacks, is skipped with the reason and nothing is moved or written. A paused project ' \
31
+ "can be rolled back: pausing only keeps fetch and sync away.\n\n" \
32
+ 'The project prints one line, one of the results listed below, with a reason in ' \
33
+ "parentheses and the details under it.\n\n" \
34
+ 'With --dry-run nothing is moved or written, and the line ends in (dry run).'
35
+ EXIT_STATUSES = Manual::EXIT_STATUSES.merge(
36
+ '0' => 'The project was rolled back, or already stood at the revision and was held there.',
37
+ '1' => 'Runtime error, such as a missing resource or an unreadable manifest, or a project that was ' \
38
+ 'skipped or whose move was denied or failed.'
39
+ ).freeze
40
+ TO_REVISION = CLI::Option.new(long: 'to-revision', argument: 'N',
41
+ description: 'The revision to roll back to, as slipway rollout history ' \
42
+ 'numbers it. 0, the default, is the revision before the ' \
43
+ 'current one.')
44
+ REVISION_INVALID = 'invalid argument %p for --to-revision: must be a revision number, or 0'
45
+ RESULTS = CLI::Glossary.new(
46
+ title: 'Results',
47
+ entries: {
48
+ Rollback::ROLLED_BACK => 'The branch moved, or it already stood at the revision and only spec.revision ' \
49
+ 'changed. The detail names the commits it crossed and the command that lets ' \
50
+ 'sync follow the upstream again.',
51
+ Outcome::UNCHANGED => 'The branch already stood at the revision and spec.revision already held it.',
52
+ "#{Outcome::SKIPPED} (Reason)" => 'Nothing moved and nothing was written. git could not read the ' \
53
+ 'repository (Missing, NotARepo, Unsafe, Unknown); the branch cannot ' \
54
+ 'move (Conflicted, Detached, Unborn, NoUpstream, Gone, InProgress); ' \
55
+ 'the history has no such revision (NoHistory, NoPrevious, ' \
56
+ 'UnknownRevision) or the repository no such commit ' \
57
+ '(RevisionNotFound); a move back would drop commits the upstream ' \
58
+ 'lacks (LocalCommits) or staged changes (Dirty), or the revision is ' \
59
+ "off the branch's history (Diverged); a move forward needs a tree " \
60
+ 'without staged or unstaged changes (Dirty) and a revision on the ' \
61
+ 'upstream (OffUpstream); or git refused the move (WouldLoseChanges, ' \
62
+ 'WouldOverwrite for untracked or ignored files in the way, Busy, ' \
63
+ 'NotFastForward for a branch that moved since the check).',
64
+ "#{Outcome::DENIED} (Reason)" => 'A partial clone had to fetch the files the move writes and the remote ' \
65
+ 'asked for a password, a passphrase or a host key (AuthRequired), as ' \
66
+ 'in slipway fetch.',
67
+ "#{Outcome::FAILED} (Reason)" => 'The move ran past networkTimeout (Timeout) or git failed for another ' \
68
+ 'reason (Unknown), and spec.revision was not written, though a move ' \
69
+ 'stopped at the deadline keeps the files git had already written, as ' \
70
+ 'in slipway sync; or the branch moved but spec.revision could not be ' \
71
+ "written (#{Rollback::NOT_PINNED}), and the detail names the " \
72
+ '--to-revision command that writes it without moving the branch again.'
73
+ }
74
+ )
75
+ ROLES = { Rollback::ROLLED_BACK => :result_changed, Outcome::UNCHANGED => :result_unchanged,
76
+ Outcome::SKIPPED => :result_skipped, Outcome::DENIED => :result_denied,
77
+ Outcome::FAILED => :result_failed }.freeze
78
+
79
+ def self.command(factory)
80
+ CLI::Command.new(
81
+ name: 'undo', summary: 'Undo a previous rollout', description: DESCRIPTION, usage: SINGLE_USAGE,
82
+ examples:, exit_statuses: EXIT_STATUSES, glossaries: [RESULTS],
83
+ positionals: [Options.project_positional(factory, variadic: false, required: true)],
84
+ options: [TO_REVISION, Options::DRY_RUN],
85
+ handler: new(factory)
86
+ )
87
+ end
88
+
89
+ def self.examples
90
+ [
91
+ CLI::Example.new(comment: 'Roll back project hldr to the previous revision', command: 'rollout undo hldr'),
92
+ CLI::Example.new(comment: 'Roll back project hldr to revision 3',
93
+ command: 'rollout undo hldr --to-revision=3'),
94
+ CLI::Example.new(comment: 'Show where undo would move project hldr, without moving or writing anything',
95
+ command: 'rollout undo hldr --dry-run')
96
+ ]
97
+ end
98
+ private_class_method :examples
99
+
100
+ def kinds = [Resources::PROJECTS]
101
+
102
+ def run(runtime, context, args, opts)
103
+ scope = scope(runtime, context, opts)
104
+ names = scope.project_targets(args, verb: 'roll back')
105
+ number = revision(opts[:to_revision])
106
+ dry_run = opts[:dry_run] == true
107
+ rollback = Rollback.new(runtime, dry_run:, group: opts[:group] ? nil : runtime.settings.group)
108
+ results = Results.new(context, ROLES, dry_run:)
109
+ scope.select(Resources::PROJECTS, names) do |projects|
110
+ projects.each { results.report(rollback.undo(it, number:)) }
111
+ end
112
+ raise Failed if results.any?(Outcome::SKIPPED, Outcome::DENIED, Outcome::FAILED)
113
+ end
114
+
115
+ private
116
+
117
+ def revision(value)
118
+ return if value.nil?
119
+
120
+ number = Integer(value, 10, exception: false)
121
+ number && !number.negative? ? number : raise(CLI::UsageError, format(REVISION_INVALID, value))
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../cli/errors'
4
+ require_relative '../field_selector'
5
+ require_relative '../names'
6
+ require_relative '../output'
7
+ require_relative '../resources'
8
+ require_relative '../selector'
9
+ require_relative '../store'
10
+ require_relative '../views'
11
+
12
+ module Slipway
13
+ module Commands
14
+ # Names typed on the command line are checked here, so a bad one is a usage error rather
15
+ # than a failure deep in the store.
16
+ class Scope
17
+ NONE = 'No resources found.'
18
+ NONE_IN = 'No resources found in '
19
+ GROUP_SUFFIX = ' group.'
20
+ NAMES_WITH_SELECTOR = 'name cannot be provided when a selector is specified'
21
+ NAMES_WITH_ALL_GROUPS = 'a resource cannot be retrieved by name across all groups'
22
+ MIXED_TYPES = 'all resources must share one type'
23
+ MIXED_FORMS = 'a name in TYPE/NAME form cannot be combined with a bare name'
24
+ NAME_MISSING = 'missing required argument "NAME"'
25
+
26
+ def initialize(runtime, context, opts)
27
+ @runtime = runtime
28
+ @context = context
29
+ @opts = opts
30
+ @skipped = false
31
+ end
32
+
33
+ def kind(word) = Resources.resolve(word)
34
+
35
+ # `TYPE/NAME...` is the form `get -o name` prints.
36
+ def targets(words)
37
+ type, *names = words
38
+ kind, names = type.include?('/') ? slashed(words) : [kind(type), names]
39
+ names.each { check_name(kind, it) }
40
+ [kind, names]
41
+ end
42
+
43
+ # A verb that acts on repositories names projects alone, bare or in the TYPE/NAME form
44
+ # `get projects -o name` prints, since a group holds no repository of its own.
45
+ def project_targets(words, verb:)
46
+ kind, names = words.any? { it.include?('/') } ? slashed(words) : [Resources::PROJECTS, words]
47
+ raise CLI::UsageError, "cannot #{verb} a group" unless kind == Resources::PROJECTS
48
+
49
+ names.each { check_name(kind, it) }
50
+ check_names(kind, across: "cannot #{verb} a project by name across all groups") unless names.empty?
51
+ names
52
+ end
53
+
54
+ def target(words)
55
+ kind, names = targets(words)
56
+ raise CLI::UsageError, NAME_MISSING if names.empty?
57
+ raise CLI::UsageError, "unexpected argument #{names[1].inspect}" if names.size > 1
58
+
59
+ [kind, names.first]
60
+ end
61
+
62
+ def group
63
+ flag = @opts[:group]
64
+ as_usage_error { Names.validate!(flag, what: 'group name') } unless flag.nil?
65
+ @runtime.group_for(@opts)
66
+ end
67
+
68
+ def all_groups? = @opts[:all_groups] == true
69
+
70
+ def selector = Selector.parse(@opts[:selector])
71
+
72
+ def field_selector(kind, names)
73
+ check_names(kind) unless names.empty?
74
+ FieldSelector.parse(@opts[:field_selector], fields: kind.namespaced? ? Views::Project::FIELDS : Views::Group::FIELDS)
75
+ end
76
+
77
+ # A verb whose exit status vouches for every project must not report success when a listing
78
+ # left a manifest out.
79
+ def skipped? = @skipped
80
+
81
+ # As in kubectl, the resources that exist are shown before the names that do not are
82
+ # reported, one `error:` line each.
83
+ def select(kind, names)
84
+ return yield(listed(kind)) if names.empty?
85
+
86
+ check_names(kind)
87
+ found, missing = resolve(kind, names)
88
+ yield found unless found.empty?
89
+ raise Error.new(problems: missing) unless missing.empty?
90
+ end
91
+
92
+ def report_none(kind)
93
+ return @context.warn(@context.paint_err(:muted, NONE)) if all_groups? || !kind.namespaced?
94
+
95
+ @context.warn(@context.paint_err(:muted, NONE_IN) + @context.paint_err(:string, group) +
96
+ @context.paint_err(:muted, GROUP_SUFFIX))
97
+ end
98
+
99
+ private
100
+
101
+ def slashed(words)
102
+ pairs = words.map do |word|
103
+ type, name = word.split('/', 2)
104
+ raise CLI::UsageError, MIXED_FORMS if name.nil?
105
+
106
+ [kind(type), name]
107
+ end
108
+ kinds = pairs.map(&:first).uniq
109
+ raise CLI::UsageError, MIXED_TYPES if kinds.size > 1
110
+
111
+ [kinds.first, pairs.map(&:last)]
112
+ end
113
+
114
+ def check_name(kind, name)
115
+ as_usage_error { Names.validate!(name, what: "#{kind.singular} name") }
116
+ end
117
+
118
+ def as_usage_error
119
+ yield
120
+ rescue Names::Invalid => e
121
+ raise CLI::UsageError, e.message
122
+ end
123
+
124
+ # A manifest that cannot be read is reported and skipped, so one bad file does not
125
+ # hide the rest of the registry.
126
+ def listed(kind)
127
+ matcher = selector
128
+ scope_group = kind.namespaced? && !all_groups? ? group : nil
129
+ resources = @runtime.store.list(kind, group: scope_group) do |problem|
130
+ @skipped = true
131
+ Output.warning(@context, problem.message)
132
+ end
133
+ resources.select { matcher.match?(it.labels) }
134
+ end
135
+
136
+ def resolve(kind, names)
137
+ found = []
138
+ missing = []
139
+ names.each do |name|
140
+ found << @runtime.store.find(kind, name, group:)
141
+ rescue Store::NotFound => e
142
+ missing << e.message
143
+ end
144
+ [found, missing]
145
+ end
146
+
147
+ # kubectl refuses a label or field selector or --all-namespaces next to explicit names.
148
+ # `across` replaces kubectl's "retrieved" for a verb that acts on the projects it names.
149
+ def check_names(kind, across: NAMES_WITH_ALL_GROUPS)
150
+ selectors = @opts.values_at(:selector, :field_selector)
151
+ raise CLI::UsageError, NAMES_WITH_SELECTOR unless selectors.all? { it.to_s.strip.empty? }
152
+ raise CLI::UsageError, across if kind.namespaced? && all_groups?
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'manual'
5
+ require_relative 'fetch'
6
+ require_relative 'results'
7
+ require_relative '../fetcher'
8
+ require_relative '../outcome'
9
+ require_relative '../syncer'
10
+
11
+ module Slipway
12
+ module Commands
13
+ class Sync < Base
14
+ # Raised once every line has printed: the lines already say what went wrong, so it adds no
15
+ # error line, only exit status 1.
16
+ class Failed < Error
17
+ def initialize = super(problems: [])
18
+ end
19
+
20
+ DESCRIPTION = "Fetch each selected project and fast-forward its branch.\n\n" \
21
+ 'Runs in every project of the current group, in the projects named, in the ones a label ' \
22
+ 'selector matches, or with --all-groups in every project. Each project is fetched as slipway ' \
23
+ 'fetch fetches it and compared with its manifest as slipway diff compares it, and its ' \
24
+ 'checked-out branch is fast-forwarded onto its upstream when the branch tracks an upstream ' \
25
+ 'that still exists, is behind it without commits of its own, has no staged, unstaged or ' \
26
+ 'conflicted changes and no merge, rebase or other operation in progress. Untracked files do ' \
27
+ 'not stop it, and git refuses a fast-forward that would overwrite one. Sync never merges, ' \
28
+ "rebases, stashes, resets, switches a branch or changes a remote.\n\n" \
29
+ 'A project whose spec.syncPolicy is FetchOnly is only fetched, and one whose spec.paused is ' \
30
+ 'true is left alone: no git command runs in it. A project pinned by spec.revision is ' \
31
+ 'fast-forwarded up to that commit instead of its upstream, only when its upstream holds the ' \
32
+ "commit, never past it, and never moved back to it.\n\n" \
33
+ 'Each project prints one line, one of the results listed below, with a reason in parentheses ' \
34
+ 'and the details under it. The parallel setting caps how many projects fetch at once, ' \
35
+ 'fast-forwards run one at a time, and the results print in the order the projects are ' \
36
+ "listed.\n\n" \
37
+ 'With --dry-run nothing is fetched or written: the plan is made from the last fetch.'
38
+ USAGE = '[NAME... | project/NAME...]'
39
+ EXIT_STATUSES = Manual::EXIT_STATUSES.merge(
40
+ '0' => 'Every selected project was fast-forwarded, fetched, left unchanged, skipped or paused.',
41
+ '1' => 'Runtime error, such as a missing resource or an unreadable manifest, or a project whose fetch or ' \
42
+ 'fast-forward was denied or failed.'
43
+ ).freeze
44
+ RESULTS = CLI::Glossary.new(
45
+ title: 'Results',
46
+ intro: 'When more than one project ran, a count of the results closes the run on stderr. With ' \
47
+ '--dry-run every line and the count end in (dry run). Ctrl-C stops the git processes slipway ' \
48
+ 'started and exits with status 130; a fast-forward it stops ends as one stopped at the deadline.',
49
+ entries: {
50
+ Syncer::FAST_FORWARDED => 'The branch moved. For a move onto the upstream, the detail names the commits ' \
51
+ 'it gained, as main a1b2c3d..e4f5a6b (3 commits), and the command that undoes ' \
52
+ "the move; a move up to a spec.revision pin ends in #{Syncer::TO_PIN}.",
53
+ Outcome::FETCHED => 'The fetch of a FetchOnly project moved refs; the refs follow as in slipway fetch.',
54
+ Outcome::UNCHANGED => 'The branch stayed where it was and nothing blocked it; the fetch may still have ' \
55
+ 'moved remote-tracking refs.',
56
+ "#{Outcome::SKIPPED} (Reason)" => 'The branch stayed where it was: a blocker stopped it, under the name ' \
57
+ 'slipway diff gives it; git refused the fast-forward (WouldOverwrite ' \
58
+ 'for untracked files in the way, WouldLoseChanges for local changes git ' \
59
+ 'status does not show, Busy for a lock another git process holds on ' \
60
+ 'the index, HEAD or the branch, NotFastForward for a branch that ' \
61
+ 'gained a commit since the check); or the project was skipped before ' \
62
+ 'its fetch, as in slipway fetch (Missing, NotARepo, Unsafe, Unknown, ' \
63
+ 'NoRemote, LocalUpstream).',
64
+ Outcome::PAUSED => 'spec.paused is true, so no git command ran in the project.',
65
+ "#{Outcome::DENIED} (Reason)" => 'The fetch or the fast-forward needed a password, a passphrase or a ' \
66
+ 'host key (AuthRequired), as in slipway fetch.',
67
+ "#{Outcome::FAILED} (Reason)" => 'The fetch or the fast-forward ran past networkTimeout (Timeout), used ' \
68
+ 'a transport protocols leaves out (ProtocolNotAllowed), or git failed ' \
69
+ 'for another reason (Unknown). A fast-forward stopped at the deadline ' \
70
+ 'leaves the branch where it was, but the files git had already written ' \
71
+ 'stay in the working tree, and the detail names the command that lists ' \
72
+ 'them.'
73
+ }
74
+ )
75
+ # In the order the closing summary counts them.
76
+ ROLES = { Syncer::FAST_FORWARDED => :result_changed, Outcome::FETCHED => :result_changed,
77
+ Outcome::UNCHANGED => :result_unchanged, Outcome::SKIPPED => :result_skipped,
78
+ Outcome::PAUSED => :result_paused, Outcome::DENIED => :result_denied,
79
+ Outcome::FAILED => :result_failed }.freeze
80
+ ALL_GROUPS = Options::ALL_GROUPS.with(description: 'If present, sync every project across all groups. The ' \
81
+ 'group in the current configuration is ignored even if ' \
82
+ 'specified with --group.')
83
+
84
+ def self.command(factory)
85
+ CLI::Command.new(
86
+ name: 'sync', summary: 'Fetch projects and fast-forward their branches', section: 'Repository Commands',
87
+ description: DESCRIPTION, examples:, usage: USAGE, exit_statuses: EXIT_STATUSES, glossaries: [RESULTS],
88
+ positionals: [Options.project_positional(factory)],
89
+ options: [Options::SELECTOR, ALL_GROUPS, Fetch::PRUNE, Options::DRY_RUN],
90
+ handler: new(factory)
91
+ )
92
+ end
93
+
94
+ def self.examples
95
+ [
96
+ CLI::Example.new(comment: 'Sync every project in the current group', command: 'sync'),
97
+ CLI::Example.new(comment: 'Sync every project in every group', command: 'sync -A'),
98
+ CLI::Example.new(comment: 'Sync two projects of the work group', command: 'sync api web -n work'),
99
+ CLI::Example.new(comment: 'Show what sync would do from the last fetch, without contacting any remote',
100
+ command: 'sync -A --dry-run')
101
+ ]
102
+ end
103
+ private_class_method :examples
104
+
105
+ def kinds = [Resources::PROJECTS]
106
+
107
+ def run(runtime, context, args, opts)
108
+ scope = scope(runtime, context, opts)
109
+ names = scope.project_targets(args, verb: 'sync')
110
+ dry_run = opts[:dry_run] == true
111
+ fetcher = Fetcher.new(runtime, prune: opts[:prune] == true, dry_run:)
112
+ syncer = Syncer.new(runtime, fetcher, dry_run:, group: configured_group(runtime, opts))
113
+ results = Results.new(context, ROLES, dry_run:)
114
+ scope.select(Resources::PROJECTS, names) do |projects|
115
+ next scope.report_none(Resources::PROJECTS) if projects.empty?
116
+
117
+ results.stream(projects, workers: runtime.settings.parallel, work: syncer.method(:observe)) do |step|
118
+ syncer.settle(step)
119
+ end
120
+ unfetched(context, syncer.unfetched)
121
+ results.summarize
122
+ end
123
+ raise Failed if results.any?(Outcome::DENIED, Outcome::FAILED)
124
+ end
125
+
126
+ private
127
+
128
+ # settings.group already holds a typed -n, and the undo line is run later without it.
129
+ def configured_group(runtime, opts) = opts[:group] ? nil : runtime.settings.group
130
+
131
+ def unfetched(context, count)
132
+ return if count.zero?
133
+
134
+ subject = count == 1 ? '1 project was' : "#{count} projects were"
135
+ Output.warning(context, "#{subject} never fetched and a dry run does not fetch; run 'slipway fetch' " \
136
+ 'first for an up-to-date plan')
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'version'
4
+ require_relative 'cli'
5
+ require_relative 'settings'
6
+ require_relative 'paths'
7
+ require_relative 'commands/manual'
8
+ require_relative 'commands/options'
9
+ require_relative 'commands/base'
10
+ require_relative 'commands/scope'
11
+ require_relative 'commands/get'
12
+ require_relative 'commands/describe'
13
+ require_relative 'commands/create'
14
+ require_relative 'commands/apply'
15
+ require_relative 'commands/delete'
16
+ require_relative 'commands/edit'
17
+ require_relative 'commands/label'
18
+ require_relative 'commands/explain'
19
+ require_relative 'commands/fetch'
20
+ require_relative 'commands/diff'
21
+ require_relative 'commands/sync'
22
+ require_relative 'commands/rollout'
23
+ require_relative 'commands/config'
24
+ require_relative 'commands/api_resources'
25
+
26
+ module Slipway
27
+ module Commands
28
+ PROGRAM = 'slipway'
29
+ DESCRIPTION = 'A kubectl-style registry of the git repositories on your machine'
30
+ # The space after each newline indents the paragraphs, as kubectl's root help does.
31
+ LONG_DESCRIPTION = "#{DESCRIPTION}.\n\n " \
32
+ "A project is a registered git repository. get and describe show where each one\n " \
33
+ "stands, and fetch fetches them all without prompting. Its manifest can also\n " \
34
+ "declare where the repository should be: the remote, the branch and a commit to\n " \
35
+ "hold it at. diff shows where a repository differs from that, sync fast-forwards\n " \
36
+ "the branches that can move without losing work, and rollout undo takes such a\n " \
37
+ "move back.\n\n " \
38
+ "Projects live in groups the way pods live in namespaces. The group is\n " \
39
+ "\"#{Resources::DEFAULT_GROUP}\", or the one SLIPWAY_GROUP or the group key sets, unless -n\n " \
40
+ "is given, and -A covers every group.\n\n " \
41
+ "#{Options::TYPES_SENTENCE}".freeze
42
+ # Help lists the verbs in this order within their sections.
43
+ VERBS = [Get, Describe, Create, Apply, Delete, Edit, Label, Explain, Fetch, Diff, Sync, Rollout, Config,
44
+ APIResources].freeze
45
+
46
+ def self.registry(factory)
47
+ CLI::Registry.new(program: PROGRAM, version: VERSION, description: DESCRIPTION,
48
+ long_description: LONG_DESCRIPTION,
49
+ globals: CLI::Globals.all(group_completer: Options.group_completer(factory)),
50
+ commands: VERBS.map { it.command(factory) },
51
+ builtins: { paths: Paths.method(:new) })
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'git/url'
4
+
5
+ module Slipway
6
+ # The ways a project can differ from its manifest, and the blockers that keep sync from
7
+ # resolving them. Plan decides which apply; this module holds the words and their sentences.
8
+ module Drift
9
+ # `command` is a git command line that shows or resolves the item; slipway prints it and never
10
+ # runs it. Either can quote a URL git answered with, so both are redacted here, once for every
11
+ # reader.
12
+ Item = Data.define(:type, :message, :command, :blocker) do
13
+ def initialize(type:, message:, command: nil, blocker: false)
14
+ super(type:, message: Git::Url.redact(message), command: command && Git::Url.redact(command), blocker:)
15
+ end
16
+ end
17
+
18
+ MISSING = 'Missing'
19
+ REMOTE = 'Remote'
20
+ BRANCH = 'Branch'
21
+ REVISION = 'Revision'
22
+ BEHIND = 'Behind'
23
+ # In the order they are listed.
24
+ TYPES = [MISSING, REMOTE, BRANCH, REVISION, BEHIND].freeze
25
+ # When each type is reported, in the same order. The help of diff prints these, and the README
26
+ # table says the same with its code spans marked.
27
+ TYPE_MEANINGS = {
28
+ MISSING => 'The registered path is relative or is not a directory. For a path that is not a directory, a ' \
29
+ 'project with spec.remote shows the git clone command that recreates it.',
30
+ REMOTE => 'origin is absent or differs from spec.remote. Sync never changes a remote.',
31
+ BRANCH => 'HEAD is detached or on another branch than spec.branch. Sync never switches branches.',
32
+ REVISION => 'HEAD is not the commit spec.revision pins. The pin replaces the upstream, so a pinned project is ' \
33
+ 'never Behind; under FastForward sync will fast-forward a branch behind the pin to it unless a ' \
34
+ 'blocker stops it, and never moves a branch back.',
35
+ BEHIND => 'The checked-out branch is behind its upstream. Under FastForward sync will fast-forward it unless a ' \
36
+ 'blocker stops it; under FetchOnly, or while spec.paused is true, it is only reported.'
37
+ }.freeze
38
+ # The drift sync resolves by fast-forwarding the checked-out branch.
39
+ MOVES = [REVISION, BEHIND].freeze
40
+
41
+ # In the order Plan checks them. Each sentence says what holds sync back, so the line reads
42
+ # the same in diff, describe and sync. Rollback refuses with Conflicted and InProgress too,
43
+ # so those two name no verb.
44
+ BLOCKERS = {
45
+ 'NotARepo' => '%<path>s holds files but no repository; sync clones only into an absent directory',
46
+ 'Unsafe' => '%<reason>s',
47
+ 'Unknown' => '%<reason>s',
48
+ 'Detached' => 'HEAD is detached at %<head>s; sync never moves a detached HEAD',
49
+ 'Unborn' => 'no commits yet; nothing to fast-forward',
50
+ 'Gone' => 'upstream %<upstream>s no longer exists; sync never retargets a branch',
51
+ 'NoUpstream' => '%<branch>s tracks no upstream; sync fast-forwards only a tracking branch',
52
+ 'RevisionNotFound' => 'spec.revision %<revision>s is not in this repository; fetch it or unpin',
53
+ 'PastRevision' => '%<branch>s is past the pinned revision; sync never moves a branch back',
54
+ 'OffUpstream' => 'spec.revision %<revision>s is not on %<upstream>s; sync moves a branch only along its upstream',
55
+ 'Conflicted' => '%<paths>s; finish or abort the merge first',
56
+ 'Dirty' => '%<changes>s; sync fast-forwards only a tree without staged or unstaged changes',
57
+ 'Diverged' => '%<ahead>d ahead, %<behind>d behind %<upstream>s; sync never merges or rebases',
58
+ 'InProgress' => 'a %<operation>s is in progress'
59
+ }.freeze
60
+
61
+ # What each blocker means, in the same order, for the help of diff and the README table.
62
+ BLOCKER_MEANINGS = {
63
+ 'NotARepo' => 'The directory exists but holds no repository.',
64
+ 'Unsafe' => 'git refused the repository because another user owns it (safe.directory); the git command that ' \
65
+ 'trusts it follows.',
66
+ 'Unknown' => 'git could not answer; the reason is also printed once on stderr.',
67
+ 'Detached' => 'HEAD points at a commit rather than a branch.',
68
+ 'Unborn' => 'The branch has no commits yet.',
69
+ 'Gone' => 'The upstream is configured but its ref no longer exists.',
70
+ 'NoUpstream' => 'The branch tracks no upstream.',
71
+ 'RevisionNotFound' => 'The repository has no commit by the name spec.revision pins.',
72
+ 'PastRevision' => 'HEAD is past the pinned commit or on another line of history, so reaching the pin would ' \
73
+ 'move the branch back.',
74
+ 'OffUpstream' => 'The upstream does not hold the pinned commit, which may be on another branch or a fork; sync ' \
75
+ 'moves a branch only along its upstream.',
76
+ 'Conflicted' => 'The branch is behind and the working tree has unmerged paths.',
77
+ 'Dirty' => 'The branch is behind and has staged or unstaged changes; untracked files do not block.',
78
+ 'Diverged' => 'The branch is behind and has commits of its own.',
79
+ 'InProgress' => 'The branch would be fast-forwarded, but a merge, rebase, cherry-pick, revert, bisect or ' \
80
+ 'git am is in progress.'
81
+ }.freeze
82
+
83
+ def self.blocker(word, command: nil, **values)
84
+ Item.new(type: word, message: format(BLOCKERS.fetch(word), **values), command:, blocker: true)
85
+ end
86
+ end
87
+ end