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,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'manual'
5
+ require_relative '../inspector'
6
+ require_relative '../plan'
7
+
8
+ module Slipway
9
+ module Commands
10
+ class Diff < Base
11
+ # Raised once every project has printed: the lines say what differs, so it adds no error
12
+ # line, only the exit status a script tests.
13
+ class Differs < Error
14
+ def initialize = super(problems: [])
15
+
16
+ def exit_status = 3
17
+ end
18
+
19
+ # A project that could not be compared, because its manifest could not be read or git could
20
+ # not answer for it, must not pass for one in sync. The reason is already on stderr, so this
21
+ # adds only exit status 1.
22
+ class Unanswered < Error
23
+ def initialize = super(problems: [])
24
+ end
25
+
26
+ DESCRIPTION = "Show where each selected project differs from its manifest.\n\n" \
27
+ 'Compares every project of the current group, the projects named, the ones a label selector ' \
28
+ 'matches, or with --all-groups every project, with its manifest. Each project that differs ' \
29
+ 'prints its name and one line per difference: Missing, Remote, Branch, Revision or Behind, ' \
30
+ 'then the blocker that keeps sync from fast-forwarding the branch, if any. A line is followed ' \
31
+ 'by the git command that shows or resolves it when there is one; slipway never runs it. A ' \
32
+ "project that matches its manifest prints nothing.\n\n" \
33
+ 'The repositories are read as they are on disk: diff contacts no remote and writes nothing, ' \
34
+ 'so Behind is as of the last fetch, which slipway fetch refreshes. A project pinned by ' \
35
+ 'spec.revision is compared with that commit instead of its upstream, and is blocked when the ' \
36
+ 'repository lacks the commit, HEAD is past it or its upstream does not hold it. A paused or ' \
37
+ 'FetchOnly project shows a blocker only when git cannot read its repository.'
38
+ USAGE = '[NAME... | project/NAME...]'
39
+ EXIT_STATUSES = Manual::EXIT_STATUSES.merge(
40
+ '0' => 'Every selected project matches its manifest.',
41
+ '1' => 'Runtime error, such as a missing resource or an unreadable manifest, or a project whose state is ' \
42
+ 'Unknown because git failed or did not finish.',
43
+ '3' => 'At least one selected project differs from its manifest or is blocked, including a directory that ' \
44
+ 'holds no repository (NotARepo) and a repository git refuses (Unsafe).'
45
+ ).sort_by { |status, _| Integer(status) }.to_h.freeze
46
+ GLOSSARIES = [
47
+ CLI::Glossary.new(title: 'Drift', entries: Drift::TYPE_MEANINGS),
48
+ CLI::Glossary.new(
49
+ title: 'Blockers',
50
+ intro: 'A blocker says why the checked-out branch cannot be fast-forwarded, onto its upstream or, for a ' \
51
+ 'project pinned by spec.revision, onto the pin. The first three mean git could not read the ' \
52
+ 'repository and apply to every project; the others apply only under FastForward to a project that ' \
53
+ 'is not paused, and RevisionNotFound, PastRevision and OffUpstream only to a pinned one.',
54
+ entries: Drift::BLOCKER_MEANINGS
55
+ )
56
+ ].freeze
57
+ INDENT = ' '
58
+ COMMAND_INDENT = ' '
59
+ ALL_GROUPS = Options::ALL_GROUPS.with(description: 'If present, compare every project across all groups with ' \
60
+ 'its manifest. The group in the current configuration is ' \
61
+ 'ignored even if specified with --group.')
62
+
63
+ def self.command(factory)
64
+ CLI::Command.new(
65
+ name: 'diff', summary: 'Show where projects differ from their manifests', section: 'Repository Commands',
66
+ description: DESCRIPTION, examples:, usage: USAGE, exit_statuses: EXIT_STATUSES, glossaries: GLOSSARIES,
67
+ positionals: [Options.project_positional(factory)],
68
+ options: [Options::SELECTOR, ALL_GROUPS],
69
+ handler: new(factory)
70
+ )
71
+ end
72
+
73
+ def self.examples
74
+ [
75
+ CLI::Example.new(comment: 'Show where the projects of the current group differ from their manifests',
76
+ command: 'diff'),
77
+ CLI::Example.new(comment: 'Show every project that differs, in every group', command: 'diff -A'),
78
+ CLI::Example.new(comment: 'Compare two projects of the work group', command: 'diff api web -n work'),
79
+ CLI::Example.new(comment: 'Compare the projects labeled lang=rust', command: 'diff -l lang=rust')
80
+ ]
81
+ end
82
+ private_class_method :examples
83
+
84
+ def kinds = [Resources::PROJECTS]
85
+
86
+ def run(runtime, context, args, opts)
87
+ scope = scope(runtime, context, opts)
88
+ names = scope.project_targets(args, verb: 'diff')
89
+ failure = nil
90
+ scope.select(Resources::PROJECTS, names) do |projects|
91
+ next scope.report_none(Resources::PROJECTS) if projects.empty?
92
+
93
+ failure = compare(context, Base.examine(runtime, context, projects))
94
+ end
95
+ failure = Unanswered.new if scope.skipped?
96
+ raise failure if failure
97
+ end
98
+
99
+ private
100
+
101
+ def compare(context, inspections)
102
+ plans = inspections.map { Plan.for(it) }
103
+ inspections.zip(plans).each { |inspection, plan| report(context, inspection.project, plan) }
104
+ return Unanswered.new if inspections.any? { it.state == Inspector::UNKNOWN }
105
+
106
+ Differs.new unless plans.all?(&:converged?)
107
+ end
108
+
109
+ # Messages and commands arrive redacted; git's text in them is made plain here.
110
+ def report(context, project, plan)
111
+ return if plan.converged?
112
+
113
+ lines = plan.items.flat_map do |item|
114
+ word = context.paint(item.blocker ? :status_danger : :status_warning, "#{item.type}:")
115
+ line = "#{INDENT}#{word} #{Output.plain(item.message)}"
116
+ item.command ? [line, COMMAND_INDENT + context.paint(:muted, Output.plain(item.command))] : [line]
117
+ end
118
+ context.puts("#{Resources::PROJECTS.singular}/#{project.name}", *lines)
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../editor'
5
+ require_relative '../manifest'
6
+ require_relative '../store'
7
+
8
+ module Slipway
9
+ module Commands
10
+ class Edit < Base
11
+ DESCRIPTION = "Edit a resource from the default editor.\n\n" \
12
+ 'The edit command allows you to directly edit any resource in the registry. It will open ' \
13
+ 'the editor named by the SLIPWAY_EDITOR environment variable, then the editor key of the ' \
14
+ "configuration file, then VISUAL or EDITOR, or fall back to 'vi'.\n\n" \
15
+ 'The kind, name, group and creation timestamp of a resource cannot be changed. If an error ' \
16
+ 'occurs while saving, the file is reopened with the relevant failures as comments at the ' \
17
+ "top; saving it again without changes cancels the edit.\n\n" \
18
+ "#{Options::TYPES_SENTENCE}".freeze
19
+ USAGE = '(TYPE NAME | TYPE/NAME)'
20
+ HEADER = <<~TEXT
21
+ # Please edit the object below. Lines beginning with a '#' will be ignored,
22
+ # and an empty file will abort the edit. If an error occurs while saving this
23
+ # file will be reopened with the relevant failures.
24
+ #
25
+ TEXT
26
+ UNCHANGED = 'Edit cancelled, no changes made.'
27
+ EMPTY = 'Edit cancelled, saved file was empty.'
28
+ ABORTED = 'Edit cancelled, no valid changes were saved.'
29
+ IMMUTABLE = 'kind, metadata.name and metadata.group cannot be changed'
30
+ SOURCE = 'edited manifest'
31
+ COMMENT = /\A\s*#/
32
+
33
+ class Aborted < Slipway::Error; end
34
+
35
+ def self.command(factory)
36
+ CLI::Command.new(
37
+ name: 'edit', summary: 'Edit a resource from the default editor', section: 'Basic Commands',
38
+ description: DESCRIPTION, examples:, usage: USAGE,
39
+ positionals: [Options::TYPE, Options.name_positional(factory, variadic: false, required: false)],
40
+ handler: new(factory)
41
+ )
42
+ end
43
+
44
+ def self.examples
45
+ [
46
+ CLI::Example.new(comment: "Edit the project named 'hldr'", command: 'edit project hldr'),
47
+ CLI::Example.new(comment: 'Edit a project in the work group', command: 'edit project job -n work'),
48
+ CLI::Example.new(comment: "Edit the group named 'work'", command: 'edit group work')
49
+ ]
50
+ end
51
+ private_class_method :examples
52
+
53
+ def kinds = Resources::KINDS
54
+
55
+ def run(runtime, context, args, opts)
56
+ scope = scope(runtime, context, opts)
57
+ kind, name = scope.target(args)
58
+ resource = runtime.store.find(kind, name, group: scope.group)
59
+ editor = Editor.new(env: context.env, preferred: runtime.settings.editor)
60
+ edited = Session.new(editor, kind, resource).edit
61
+ return context.warn(UNCHANGED) if edited.nil?
62
+
63
+ finish(runtime, context, kind, resource, edited)
64
+ end
65
+
66
+ private
67
+
68
+ # kubectl reports `skipped` when the text changed but the object did not.
69
+ def finish(runtime, context, kind, original, edited)
70
+ return result_line(context, kind, original.name, 'skipped', :apply_unchanged) if edited == original
71
+
72
+ runtime.store.save(edited)
73
+ result_line(context, kind, original.name, 'edited', :apply_configured)
74
+ end
75
+
76
+ class Session
77
+ def initialize(editor, kind, resource)
78
+ @editor = editor
79
+ @kind = kind
80
+ @resource = resource
81
+ @original = Manifest.dump(resource)
82
+ end
83
+
84
+ def edit
85
+ text = @original
86
+ problem = nil
87
+ loop do
88
+ edited = strip(@editor.edit(buffer(text, problem), filename: "#{@resource.name}.yaml"))
89
+ raise Aborted, EMPTY if edited.strip.empty?
90
+ raise Aborted, ABORTED if problem && edited == text
91
+ return nil if edited == @original
92
+
93
+ return parse(edited)
94
+ rescue Manifest::Invalid => e
95
+ problem = e.problem
96
+ text = edited
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ # The failure is worded as kubectl words it.
103
+ def buffer(text, problem)
104
+ return "#{HEADER}#{text}" if problem.nil?
105
+
106
+ "#{HEADER}# #{@kind.plural} #{@resource.name.inspect} was not valid:\n# * #{problem}\n#\n#{text}"
107
+ end
108
+
109
+ def strip(text) = text.lines.grep_v(COMMENT).join
110
+
111
+ # The stored creationTimestamp is kept whatever the buffer says, as apply keeps it.
112
+ def parse(text)
113
+ parsed = Manifest.parse_yaml(text, source: SOURCE, default_group: group)
114
+ raise Manifest::Invalid.new(SOURCE, IMMUTABLE) unless same_identity?(parsed)
115
+
116
+ parsed.with(created_at: @resource.created_at)
117
+ end
118
+
119
+ def group = @kind.namespaced? ? @resource.group : Store::DEFAULT_GROUP
120
+
121
+ def same_identity?(parsed)
122
+ parsed.instance_of?(@kind.klass) && parsed.name == @resource.name &&
123
+ (!@kind.namespaced? || parsed.group == @resource.group)
124
+ end
125
+ end
126
+
127
+ private_constant :Session
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../cli'
4
+ require_relative '../resources'
5
+ require_relative '../schema'
6
+ require_relative '../output'
7
+ require_relative 'options'
8
+
9
+ module Slipway
10
+ module Commands
11
+ # Reads only Schema, so it builds no runtime and answers even where the configuration file is
12
+ # broken or git is missing.
13
+ class Explain
14
+ DESCRIPTION = "Describe fields and structure of the resource types.\n\n" \
15
+ 'This command describes the fields of each resource type, as a manifest holds them. Fields ' \
16
+ 'are identified by a dotted path after the type word, such as project.spec.syncPolicy. Each ' \
17
+ 'field shows its type, -required- when a manifest must have it, and a description with the ' \
18
+ 'rule its value follows and its default; --recursive prints the whole tree of names and ' \
19
+ "types instead.\n\n" \
20
+ "#{Options::TYPES_SENTENCE} Field names are case-sensitive, as in a manifest.".freeze
21
+ USAGE = 'TYPE[.FIELD...]'
22
+ SEPARATOR = '.'
23
+ RECURSIVE = CLI::Option.new(long: 'recursive',
24
+ description: 'If present, print the name of all the fields recursively. ' \
25
+ 'Otherwise, print the available fields with their description.')
26
+
27
+ def self.command(_factory)
28
+ CLI::Command.new(
29
+ name: 'explain', summary: 'Get documentation for a resource', section: 'Basic Commands',
30
+ description: DESCRIPTION, examples:, usage: USAGE, options: [RECURSIVE], handler: new,
31
+ positionals: [CLI::Positional.new(name: 'TYPE', completer: ->(_given, current) { paths(current) })]
32
+ )
33
+ end
34
+
35
+ def self.examples
36
+ [
37
+ CLI::Example.new(comment: 'Get the documentation of the resource and its fields',
38
+ command: 'explain projects'),
39
+ CLI::Example.new(comment: 'Get all the fields in the resource', command: 'explain projects --recursive'),
40
+ CLI::Example.new(comment: 'Get the documentation of a specific field of a resource',
41
+ command: 'explain project.spec.syncPolicy')
42
+ ]
43
+ end
44
+ private_class_method :examples
45
+
46
+ # The type words, then the fields one level under the path typed so far, as whole paths.
47
+ # A path with fields under it goes on after a dot, so the shell adds no space after it. A
48
+ # path naming no type or field has no candidates, never an error in the shell.
49
+ def self.paths(current)
50
+ parent, dot, = current.rpartition(SEPARATOR)
51
+ offered = (dot.empty? ? types : children(parent)).select { |path, _, _| path.start_with?(current) }
52
+ candidates = offered.to_h { |path, description, _| [path, description] }
53
+ offered.any? { |_, _, continues| continues } ? CLI::Completer::NoSpace.new(candidates) : candidates
54
+ rescue Slipway::Error
55
+ []
56
+ end
57
+
58
+ def self.types = Options::TYPE_DESCRIPTIONS.map { |word, description| [word, description, true] }
59
+
60
+ def self.children(path)
61
+ _, field = lookup(path)
62
+ field.fields.map { ["#{path}#{SEPARATOR}#{it.name}", "<#{it.type}>", !it.fields.empty?] }
63
+ end
64
+
65
+ # The kind the type word names and the field the path leads to; raises UsageError for a field
66
+ # that does not exist, in kubectl's words.
67
+ def self.lookup(path)
68
+ # split turns an empty word into no words at all, not into one empty type word.
69
+ type, *names = path.split(SEPARATOR, -1)
70
+ kind = Resources.resolve(type || '')
71
+ field = names.reduce(Schema::KINDS.fetch(kind.title)) do |parent, name|
72
+ parent.field(name) || raise(CLI::UsageError, "field #{name.inspect} does not exist")
73
+ end
74
+ [kind, field]
75
+ end
76
+ private_class_method :types, :children
77
+
78
+ def kinds = Resources::KINDS
79
+
80
+ def call(context, args, opts)
81
+ kind, field = Explain.lookup(args.fetch(0))
82
+ Output::Explain.new(context).print(kind: kind.title, field: document(field),
83
+ named: args.fetch(0).include?(SEPARATOR), recursive: opts[:recursive])
84
+ end
85
+
86
+ private
87
+
88
+ def document(field)
89
+ Output::Explain::Field.new(name: field.name, type: field.type, required: field.required,
90
+ description: description(field), fields: field.fields.map { document(it) })
91
+ end
92
+
93
+ # The Kubernetes API reference words a default the same way: "Defaults to 1."
94
+ def description(field) = field.default.nil? ? field.meaning : "#{field.meaning} Defaults to #{field.default}."
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'results'
5
+ require_relative '../fetcher'
6
+ require_relative '../outcome'
7
+
8
+ module Slipway
9
+ module Commands
10
+ class Fetch < Base
11
+ # Raised once every line has printed: the lines already say what went wrong, so it adds no
12
+ # error line, only exit status 1.
13
+ class Failed < Error
14
+ def initialize = super(problems: [])
15
+ end
16
+
17
+ DESCRIPTION = "Fetch from the remote of each selected project.\n\n" \
18
+ 'Runs git fetch in every project of the current group, in the projects named, in the ones ' \
19
+ 'a label selector matches, or with --all-groups in every project. Git fetches from the ' \
20
+ 'remote of the checked-out branch, else from the only remote, else from origin, as a git ' \
21
+ 'fetch typed in the repository would, and updates the references that remote\'s fetch ' \
22
+ 'refspecs name (remote-tracking references by default) and its tags, never the ' \
23
+ 'checked-out branch or the working tree. The parallel setting caps how many projects ' \
24
+ "fetch at once, and the results print in the order the projects are listed.\n\n" \
25
+ 'Each project prints one line, one of the results listed below, with a reason in parentheses ' \
26
+ 'and the details under it. A project whose repository cannot be read, or in which git finds ' \
27
+ 'no remote to fetch from, is skipped before git fetch runs. A project whose spec.paused is ' \
28
+ "true is paused: no git command runs in it.\n\n" \
29
+ 'Git never prompts: a fetch that needs a password, a passphrase or a host key is denied, one ' \
30
+ 'that runs past the networkTimeout setting is killed, and only the transports in the ' \
31
+ 'protocols setting are allowed. The exit status is 1 when any project was denied or failed.'
32
+ USAGE = '[NAME... | project/NAME...]'
33
+ # In the order the closing summary counts them.
34
+ ROLES = { Outcome::FETCHED => :result_changed, Outcome::UNCHANGED => :result_unchanged,
35
+ Outcome::SKIPPED => :result_skipped, Outcome::PAUSED => :result_paused,
36
+ Outcome::DENIED => :result_denied, Outcome::FAILED => :result_failed }.freeze
37
+
38
+ RESULTS = CLI::Glossary.new(
39
+ title: 'Results',
40
+ intro: 'When more than one project ran, a count of the results closes the run on stderr. With ' \
41
+ '--dry-run no remote is contacted: a project that would be fetched reads fetched, and every ' \
42
+ 'line and the count end in (dry run). Ctrl-C stops the git processes slipway started and exits with ' \
43
+ 'status 130.',
44
+ entries: {
45
+ Outcome::FETCHED => "The remote moved refs, and up to #{Fetcher::REF_LIMIT} follow: origin/main " \
46
+ 'a1b2c3d..e4f5a6b for a ref that moved, origin/feature d09a085 (new) for a new one ' \
47
+ 'and origin/feature deleted (was d09a085) for one --prune removed. A last line, and ' \
48
+ 'N more, counts the rest. Tags appear under their bare name. With git before 2.41, ' \
49
+ 'every fetch that succeeds reads fetched, without the refs.',
50
+ Outcome::UNCHANGED => 'The remote answered and had nothing new.',
51
+ "#{Outcome::SKIPPED} (Reason)" => 'No fetch ran: git could not read the repository (Missing, NotARepo, ' \
52
+ 'Unsafe, Unknown), git has no remote to pick because there is no ' \
53
+ 'upstream, no origin and either no remote or more than one ' \
54
+ "(#{Fetcher::NO_REMOTE}), or the branch tracks a local branch " \
55
+ '(LocalUpstream).',
56
+ Outcome::PAUSED => 'spec.paused is true, so no git command ran in the project.',
57
+ "#{Outcome::DENIED} (Reason)" => 'Git needed a password, a passphrase or a host key (AuthRequired). Run ' \
58
+ 'the git -C PATH fetch printed below it once in a terminal to see what ' \
59
+ 'git needs.',
60
+ "#{Outcome::FAILED} (Reason)" => 'The fetch ran past networkTimeout (Timeout), used a transport ' \
61
+ 'protocols leaves out (ProtocolNotAllowed), or git failed for another ' \
62
+ 'reason (Unknown).'
63
+ }
64
+ )
65
+ ALL_GROUPS = Options::ALL_GROUPS.with(description: 'If present, fetch every project across all groups. The ' \
66
+ 'group in the current configuration is ignored even if ' \
67
+ 'specified with --group.')
68
+ PRUNE = CLI::Option.new(long: 'prune', description: 'Before fetching, remove any remote-tracking references ' \
69
+ 'that no longer exist on the remote.')
70
+
71
+ def self.command(factory)
72
+ CLI::Command.new(
73
+ name: 'fetch', summary: 'Fetch from the remote of each project', section: 'Repository Commands',
74
+ description: DESCRIPTION, examples:, usage: USAGE, glossaries: [RESULTS],
75
+ positionals: [Options.project_positional(factory)],
76
+ options: [Options::SELECTOR, ALL_GROUPS, PRUNE, Options::DRY_RUN],
77
+ handler: new(factory)
78
+ )
79
+ end
80
+
81
+ def self.examples
82
+ [
83
+ CLI::Example.new(comment: 'Fetch every project in the current group', command: 'fetch'),
84
+ CLI::Example.new(comment: 'Fetch every project in every group', command: 'fetch -A'),
85
+ CLI::Example.new(comment: 'Fetch two projects of the work group', command: 'fetch api web -n work'),
86
+ CLI::Example.new(comment: 'Fetch the projects labeled lang=rust and prune deleted branches',
87
+ command: 'fetch -l lang=rust --prune'),
88
+ CLI::Example.new(comment: 'List the projects a fetch would reach, without contacting any remote',
89
+ command: 'fetch -A --dry-run')
90
+ ]
91
+ end
92
+ private_class_method :examples
93
+
94
+ def kinds = [Resources::PROJECTS]
95
+
96
+ def run(runtime, context, args, opts)
97
+ scope = scope(runtime, context, opts)
98
+ names = scope.project_targets(args, verb: 'fetch')
99
+ dry_run = opts[:dry_run] == true
100
+ fetcher = Fetcher.new(runtime, prune: opts[:prune] == true, dry_run:)
101
+ results = Results.new(context, ROLES, dry_run:)
102
+ scope.select(Resources::PROJECTS, names) do |projects|
103
+ next scope.report_none(Resources::PROJECTS) if projects.empty?
104
+
105
+ results.stream(projects, workers: runtime.settings.parallel, work: fetcher.method(:attempt))
106
+ results.summarize
107
+ end
108
+ raise Failed if results.any?(Outcome::DENIED, Outcome::FAILED)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../error'
4
+ require_relative '../git/errors'
5
+ require_relative '../git/url'
6
+ require_relative '../names'
7
+ require_relative '../output'
8
+ require_relative '../paths'
9
+ require_relative '../resources'
10
+ require_relative '../scanner'
11
+ require_relative '../store'
12
+
13
+ module Slipway
14
+ module Commands
15
+ # Registers the repositories Scanner finds for create project --from-dir. Each is keyed by
16
+ # its path within the group, so a second run over the same directory adds only new clones.
17
+ class FromDir
18
+ # `created` is false for a path the group already held; `project` is then the stored one.
19
+ Entry = Data.define(:project, :created)
20
+
21
+ NONE_FOUND = 'no git repositories found under %s to depth %d'
22
+ TAKEN = 'project %p already exists at %s'
23
+ ORIGIN_FIELD = 'origin'
24
+ NOT_UTF8 = 'path is not valid UTF-8'
25
+ SEPARATOR = /[^a-z0-9-]+/
26
+ EDGE_DASHES = /\A-+|-+\z/
27
+
28
+ attr_reader :problems
29
+
30
+ def initialize(runtime, context, group:, labels:, dry_run:)
31
+ @store = runtime.store
32
+ @git = runtime.git
33
+ @home = File.expand_path(runtime.paths.home)
34
+ @context = context
35
+ @group = group
36
+ @labels = labels
37
+ @dry_run = dry_run
38
+ @problems = []
39
+ end
40
+
41
+ def register(directory, depth:)
42
+ raise Store::NotFound.of(Resources::GROUPS, @group) unless @store.group_available?(@group)
43
+
44
+ scan = Scanner.scan(File.absolute_path(Paths.expand(directory, home: @home)), depth:)
45
+ @problems.concat(scan.problems.map { "#{stored_path(it.path)}: #{it.detail}" })
46
+ raise Error, format(NONE_FOUND, directory, depth) if scan.repositories.empty? && scan.problems.empty?
47
+
48
+ index_registered
49
+ scan.repositories.filter_map { entry(it) }
50
+ end
51
+
52
+ private
53
+
54
+ # A failure is kept with the path it concerns, so the other repositories are still registered.
55
+ def entry(path)
56
+ shown = stored_path(path)
57
+ raise Error, NOT_UTF8 unless path.valid_encoding?
58
+
59
+ existing = @by_path[identity(path)]
60
+ return Entry.new(project: existing, created: false) if existing
61
+
62
+ Entry.new(project: create(path, shown), created: true)
63
+ rescue Error => e
64
+ @problems << problem(shown, e)
65
+ nil
66
+ end
67
+
68
+ # A git error starts with the absolute path, which the line already names as the manifest would.
69
+ def problem(shown, error)
70
+ detail = error.is_a?(Git::Error) ? error.message.delete_prefix("#{error.path}: ") : error.message
71
+ ["#{shown}: #{detail}", error.hint].compact.join('. ')
72
+ end
73
+
74
+ def create(path, shown)
75
+ project = Project.new(name: claim(name_for(path)), group: @group, labels: @labels, path: shown,
76
+ remote: remote(path, shown))
77
+ project = @store.create(project) unless @dry_run
78
+ remember(project)
79
+ project
80
+ end
81
+
82
+ def name_for(path)
83
+ Names.validate!(File.basename(path).downcase.gsub(SEPARATOR, '-').gsub(EDGE_DASHES, ''), what: 'project name')
84
+ end
85
+
86
+ # The listing skips a manifest it cannot read, whose name the store still holds.
87
+ def claim(name)
88
+ taken = @by_name[name]
89
+ raise Error, format(TAKEN, name, taken.path) if taken
90
+
91
+ unreadable = @store.exist?(Resources::PROJECTS, name, group: @group)
92
+ raise Store::Conflict, "project #{name.inspect} already exists" if unreadable
93
+
94
+ name
95
+ end
96
+
97
+ # A manifest that cannot be read is reported the way get reports it.
98
+ def index_registered
99
+ @by_path = {}
100
+ @by_name = {}
101
+ @store.list(Resources::PROJECTS, group: @group) { Output.warning(@context, it.message) }.each { remember(it) }
102
+ end
103
+
104
+ # Slipway never expands a ~user path, so such a project holds its name but no directory.
105
+ def remember(project)
106
+ path = Paths.expand(project.path, home: @home)
107
+ @by_path[identity(File.absolute_path(path))] = project if File.absolute_path?(path)
108
+ @by_name[project.name] = project
109
+ end
110
+
111
+ # A clone reached through a symbolic link, or registered from a directory reached through
112
+ # one, is still the same clone; a stored path that no longer resolves keeps its spelling.
113
+ def identity(path)
114
+ File.realpath(path)
115
+ rescue SystemCallError
116
+ path
117
+ end
118
+
119
+ # A remote that is refused once its credentials are dropped is left out rather than
120
+ # failing the registration: the path alone is enough to track the clone. git config reads a
121
+ # repository it distrusts or cannot open as one without an origin, so a missing origin is
122
+ # trusted only once git has read the repository.
123
+ def remote(path, shown)
124
+ url = @git.remote_url(path)
125
+ @git.status(path) if url.nil?
126
+ url && Git::Url.validate!(Git::Url.without_credentials(url), field: ORIGIN_FIELD)
127
+ rescue Git::Url::Invalid => e
128
+ Output.warning(@context, "#{shown}: spec.remote left out: #{e.message}")
129
+ nil
130
+ end
131
+
132
+ # Under HOME the path is written with ~/, so the manifest names the same clone on another machine.
133
+ def stored_path(path)
134
+ return '~' if path == @home
135
+ return path unless path.start_with?("#{@home}/")
136
+
137
+ "~/#{path.delete_prefix("#{@home}/")}"
138
+ end
139
+ end
140
+ end
141
+ end