moonraker-bosun 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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE +21 -0
  4. data/README.ja.md +467 -0
  5. data/README.md +124 -0
  6. data/docs/design.md +358 -0
  7. data/docs/moonraker-api.md +108 -0
  8. data/exe/bosun +7 -0
  9. data/lib/moonraker/bosun/cli.rb +64 -0
  10. data/lib/moonraker/bosun/client.rb +147 -0
  11. data/lib/moonraker/bosun/commands/add.rb +38 -0
  12. data/lib/moonraker/bosun/commands/audit.rb +48 -0
  13. data/lib/moonraker/bosun/commands/backup.rb +135 -0
  14. data/lib/moonraker/bosun/commands/base.rb +61 -0
  15. data/lib/moonraker/bosun/commands/diff.rb +29 -0
  16. data/lib/moonraker/bosun/commands/export.rb +103 -0
  17. data/lib/moonraker/bosun/commands/fetching.rb +76 -0
  18. data/lib/moonraker/bosun/commands/forget.rb +26 -0
  19. data/lib/moonraker/bosun/commands/get.rb +57 -0
  20. data/lib/moonraker/bosun/commands/ls.rb +43 -0
  21. data/lib/moonraker/bosun/commands/pull.rb +52 -0
  22. data/lib/moonraker/bosun/commands/push.rb +136 -0
  23. data/lib/moonraker/bosun/commands/removing.rb +38 -0
  24. data/lib/moonraker/bosun/commands/rm.rb +105 -0
  25. data/lib/moonraker/bosun/commands/roots.rb +31 -0
  26. data/lib/moonraker/bosun/commands/status.rb +22 -0
  27. data/lib/moonraker/bosun/commands/status_report.rb +75 -0
  28. data/lib/moonraker/bosun/config.rb +31 -0
  29. data/lib/moonraker/bosun/context.rb +26 -0
  30. data/lib/moonraker/bosun/error.rb +8 -0
  31. data/lib/moonraker/bosun/file_rules.rb +37 -0
  32. data/lib/moonraker/bosun/git.rb +139 -0
  33. data/lib/moonraker/bosun/host_tracking.rb +214 -0
  34. data/lib/moonraker/bosun/messages/catalog.rb +837 -0
  35. data/lib/moonraker/bosun/messages.rb +42 -0
  36. data/lib/moonraker/bosun/push_plan.rb +89 -0
  37. data/lib/moonraker/bosun/sync_status.rb +130 -0
  38. data/lib/moonraker/bosun/version.rb +7 -0
  39. data/lib/moonraker/bosun.rb +31 -0
  40. metadata +86 -0
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "net/http"
6
+ require "openssl"
7
+ require "uri"
8
+
9
+ module Moonraker
10
+ module Bosun
11
+ # Moonraker APIのクライアント
12
+ #
13
+ # APIの動作は docs/moonraker-api.md を参照。
14
+ class Client
15
+ FileInfo = Data.define(:path, :modified, :size, :permissions) do
16
+ def writable? = permissions == "rw"
17
+ end
18
+
19
+ RootInfo = Data.define(:name, :path, :permissions)
20
+
21
+ # MoonrakerがエラーのHTTPステータスを返した場合のエラー
22
+ class APIError < Error
23
+ attr_reader :code
24
+
25
+ def initialize(code, message)
26
+ @code = code
27
+ super(Messages.t(:"client.api_error", code: code, message: message))
28
+ end
29
+ end
30
+
31
+ attr_reader :base_url
32
+
33
+ def initialize(base_url, open_timeout: 5, read_timeout: 30)
34
+ @base_url = base_url
35
+ @open_timeout = open_timeout
36
+ @read_timeout = read_timeout
37
+ end
38
+
39
+ def roots
40
+ get_json("/server/files/roots").map do |item|
41
+ RootInfo.new(name: item.fetch("name"), path: item.fetch("path"), permissions: item.fetch("permissions"))
42
+ end
43
+ end
44
+
45
+ def list_files(root)
46
+ get_json("/server/files/list", root: root).map do |item|
47
+ FileInfo.new(
48
+ path: item.fetch("path"),
49
+ modified: item.fetch("modified"),
50
+ size: item.fetch("size"),
51
+ permissions: item.fetch("permissions")
52
+ )
53
+ end
54
+ end
55
+
56
+ # Moonraker DBのnamespaceの一覧
57
+ def db_namespaces
58
+ get_json("/server/database/list").fetch("namespaces")
59
+ rescue KeyError => e
60
+ raise Error, Messages.t(:"client.invalid_response", context: "database/list", message: e.message)
61
+ end
62
+
63
+ # namespaceの内容(Hash)
64
+ def db_namespace(namespace)
65
+ get_json("/server/database/item", namespace: namespace).fetch("value")
66
+ rescue KeyError => e
67
+ raise Error, Messages.t(:"client.invalid_response", context: "database/item", message: e.message)
68
+ end
69
+
70
+ # print_stats.state を返す。Klipperがreadyでない場合(print_statsが空の場合)はnil
71
+ def print_state
72
+ get_json("/printer/objects/query", print_stats: nil).dig("status", "print_stats", "state")
73
+ end
74
+
75
+ # ファイルの内容をバイナリの文字列で返す
76
+ def download(root, path)
77
+ request(Net::HTTP::Get.new(uri(file_path(root, path)))).body.b
78
+ end
79
+
80
+ def delete_file(root, path)
81
+ request(Net::HTTP::Delete.new(uri(file_path(root, path))))
82
+ nil
83
+ end
84
+
85
+ # ファイルをアップロードする。同名のファイルがあれば上書きする(呼び出し側で確認すること)
86
+ #
87
+ # checksumにより、Moonraker側で内容が一致しなければ書き込まれない。
88
+ def upload(root, path, content)
89
+ dir = File.dirname(path)
90
+ fields = [["root", root]]
91
+ fields << ["path", dir] unless dir == "."
92
+ fields << ["checksum", Digest::SHA256.hexdigest(content)]
93
+ fields << ["file", content.b, { filename: File.basename(path), content_type: "application/octet-stream" }]
94
+ req = Net::HTTP::Post.new(uri("/server/files/upload"))
95
+ req.set_form(fields, "multipart/form-data")
96
+ JSON.parse(request(req).body)
97
+ rescue JSON::ParserError => e
98
+ raise Error, Messages.t(:"client.invalid_response", context: "upload", message: e.message)
99
+ end
100
+
101
+ private
102
+
103
+ def get_json(path, **query)
104
+ response = request(Net::HTTP::Get.new(uri(path, query)))
105
+ JSON.parse(response.body).fetch("result")
106
+ rescue JSON::ParserError, KeyError => e
107
+ raise Error, Messages.t(:"client.invalid_response", context: path, message: e.message)
108
+ end
109
+
110
+ def file_path(root, path)
111
+ segments = [root, *path.split("/")]
112
+ "/server/files/#{segments.map { |s| URI.encode_uri_component(s) }.join('/')}"
113
+ end
114
+
115
+ def uri(path, query = {})
116
+ uri = @base_url.dup
117
+ uri.path = @base_url.path.chomp("/") + path
118
+ uri.query = query.empty? ? nil : URI.encode_www_form(query)
119
+ uri
120
+ end
121
+
122
+ def request(req)
123
+ response = Net::HTTP.start(
124
+ req.uri.host, req.uri.port,
125
+ use_ssl: req.uri.scheme == "https",
126
+ open_timeout: @open_timeout, read_timeout: @read_timeout
127
+ ) { |http| http.request(req) }
128
+ raise api_error(response) unless response.is_a?(Net::HTTPSuccess)
129
+
130
+ response
131
+ rescue SystemCallError, SocketError, IOError, Timeout::Error,
132
+ Net::HTTPBadResponse, Net::ProtocolError, OpenSSL::SSL::SSLError => e
133
+ raise Error, Messages.t(:"client.connection_failed", url: @base_url, message: e.message)
134
+ end
135
+
136
+ # エラーの応答にはMoonraker内部のトレースバックが含まれるので、messageだけを使う
137
+ def api_error(response)
138
+ message = begin
139
+ JSON.parse(response.body).dig("error", "message")
140
+ rescue JSON::ParserError, TypeError
141
+ nil
142
+ end
143
+ APIError.new(response.code.to_i, message || response.message)
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moonraker
4
+ module Bosun
5
+ module Commands
6
+ class Add < Base
7
+ include Fetching
8
+
9
+ def run(args)
10
+ args = parse_options(args)
11
+ return 0 unless args
12
+ raise Error, t(:"add.no_files") if args.empty?
13
+
14
+ args.each { |path| validate_path!(path) }
15
+ paths = args.uniq
16
+ tracking.check_workspace!(paths.map { |path| HostTracking.repo_path(path) })
17
+
18
+ infos = list_config_files
19
+ paths.each { |path| check_target!(path, infos[path]) }
20
+
21
+ commit_and_merge("add", fetch(paths, infos))
22
+ 0
23
+ end
24
+
25
+ private
26
+
27
+ def check_target!(path, info)
28
+ raise Error, t(:"common.file_not_on_host", path: "#{FileRules::CONFIG_ROOT}/#{path}") unless info
29
+
30
+ reason = FileRules.exclusion(FileRules::CONFIG_ROOT, info)
31
+ return unless reason
32
+
33
+ raise Error, t(:"add.excluded", reason: FileRules.reason_text(reason), path: path)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moonraker
4
+ module Bosun
5
+ module Commands
6
+ class Audit < Base
7
+ include StatusReport
8
+
9
+ def run(args)
10
+ args = parse_options(args)
11
+ return 0 unless args
12
+
13
+ check_no_arguments!(args)
14
+
15
+ git = @context.git
16
+ tracking = HostTracking.new(git)
17
+ problems = tracking.check_invariants
18
+ status = SyncStatus.new(git: git, client: client, tracking: tracking, strict: true)
19
+
20
+ report(git, status)
21
+ print_invariants(problems)
22
+
23
+ # トラッキングしていないファイルは、あえてトラッキングしないこともあるので問題としない
24
+ ok = problems.empty? && !status.unmerged? && status.renames.empty? &&
25
+ status.entries.all? { |entry| %i[synced untracked].include?(entry.state) }
26
+ out.puts
27
+ out.puts ok ? t(:"audit.ok") : t(:"audit.ng")
28
+ ok ? 0 : 1
29
+ end
30
+
31
+ private
32
+
33
+ def print_invariants(problems)
34
+ out.puts
35
+ if problems.empty?
36
+ out.puts t(:"audit.invariants_ok", branch: HostTracking::BRANCH)
37
+ return
38
+ end
39
+
40
+ out.puts t(:"audit.invariants_violated", branch: HostTracking::BRANCH)
41
+ problems.each { |problem| out.puts " #{problem}" }
42
+ out.puts " → #{t(:"audit.repair_guide", branch: HostTracking::BRANCH)}"
43
+ out.puts " → #{t(:"audit.design_guide")}"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Moonraker
6
+ module Bosun
7
+ module Commands
8
+ class Backup < Base
9
+ include Fetching
10
+
11
+ # まだ何もバックアップしていないときに、ホストにあればバックアップするnamespace
12
+ # (Web UIなどの設定と、Moonrakerが管理するユーザの設定)
13
+ DEFAULT_NAMESPACES = %w[mainsail fluidd mobileraker maintenance webcams timelapse].freeze
14
+ NAMESPACE_PATTERN = /\A[A-Za-z0-9_.-]+\z/
15
+
16
+ def run(args)
17
+ status = false
18
+ args = parse_options(args) do |o|
19
+ o.on("-s", "--status", t(:"backup.status_option")) { status = true }
20
+ end
21
+ return 0 unless args
22
+
23
+ if status
24
+ check_no_arguments!(args, "--status")
25
+
26
+ print_status
27
+ return 0
28
+ end
29
+
30
+ args.each do |namespace|
31
+ raise Error, t(:"backup.invalid_namespace", namespace: namespace) unless NAMESPACE_PATTERN.match?(namespace)
32
+ end
33
+ backup(args.uniq)
34
+ 0
35
+ end
36
+
37
+ def self.repo_path(namespace) = "#{HostTracking::DB_BACKUP_DIR}/#{namespace}.json"
38
+
39
+ private
40
+
41
+ # host-trackingでバックアップしているnamespace
42
+ def tracked_namespaces
43
+ tip = tracking.tip
44
+ return [] unless tip
45
+
46
+ @context.git.ls_tree_paths(tip, HostTracking::DB_BACKUP_DIR)
47
+ .map { |path| File.basename(path, ".json") }
48
+ end
49
+
50
+ def backup(requested)
51
+ tracked = tracked_namespaces
52
+ host = client.db_namespaces
53
+ missing = []
54
+ targets =
55
+ if !requested.empty?
56
+ missing_requested = requested - host
57
+ unless missing_requested.empty?
58
+ raise Error, t(:"backup.missing_requested", namespaces: missing_requested.join(", "))
59
+ end
60
+
61
+ requested
62
+ elsif tracked.empty?
63
+ out.puts t(:"backup.first", namespaces: DEFAULT_NAMESPACES.join(", "))
64
+ DEFAULT_NAMESPACES & host
65
+ else
66
+ missing = tracked - host
67
+ tracked & host
68
+ end
69
+ raise Error, t(:"backup.no_targets") if targets.empty?
70
+
71
+ tracking.check_workspace!(targets.map { |namespace| self.class.repo_path(namespace) })
72
+
73
+ fetched = targets.map do |namespace|
74
+ f = HostTracking::Fetched.new(repo_path: self.class.repo_path(namespace),
75
+ content: serialize(client.db_namespace(namespace)), info: nil)
76
+ out.puts t(:"common.fetched", path: f.repo_path)
77
+ f
78
+ end
79
+ print_notices(missing, requested.empty? && !tracked.empty? ? (DEFAULT_NAMESPACES & host) - tracked : [])
80
+ commit_and_merge("backup", fetched)
81
+ end
82
+
83
+ # キーを辞書順に並べた、整形したJSON(Moonrakerが返すキーの順が変わっても差分が出ないようにする)
84
+ def serialize(value)
85
+ "#{JSON.pretty_generate(sort_keys(value))}\n"
86
+ end
87
+
88
+ def sort_keys(value)
89
+ case value
90
+ when Hash then value.keys.sort.to_h { |key| [key, sort_keys(value[key])] }
91
+ when Array then value.map { |item| sort_keys(item) }
92
+ else value
93
+ end
94
+ end
95
+
96
+ def print_notices(missing, new_defaults)
97
+ unless missing.empty?
98
+ out.puts
99
+ out.puts t(:"backup.missing")
100
+ missing.each { |namespace| out.puts " #{namespace}" }
101
+ out.puts " → #{t(:"backup.missing_guide")}"
102
+ out.puts
103
+ end
104
+ return if new_defaults.empty?
105
+
106
+ out.puts
107
+ out.puts t(:"backup.recommended")
108
+ new_defaults.each { |namespace| out.puts " #{namespace}" }
109
+ out.puts " → #{t(:"backup.recommended_guide")}"
110
+ out.puts
111
+ end
112
+
113
+ def print_status
114
+ tracked = tracked_namespaces
115
+ host = client.db_namespaces
116
+ names = (host | tracked).sort
117
+ width = names.map(&:size).max || 0
118
+ names.each do |namespace|
119
+ notes = status_notes(namespace, tracked, host)
120
+ out.puts notes.empty? ? namespace : format("%-*s %s", width, namespace, notes)
121
+ end
122
+ end
123
+
124
+ # namespaceの状態(バックアップ中、ホストにない、推奨)
125
+ def status_notes(namespace, tracked, host)
126
+ notes = []
127
+ notes << :tracked if tracked.include?(namespace)
128
+ notes << :not_on_host unless host.include?(namespace)
129
+ notes << :recommended if DEFAULT_NAMESPACES.include?(namespace) && !tracked.include?(namespace)
130
+ notes.map { |note| t(:"backup.note.#{note}") }.join(t(:"backup.note_separator"))
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Moonraker
6
+ module Bosun
7
+ module Commands
8
+ class Base
9
+ include Messages
10
+
11
+ class << self
12
+ # 一覧に表示する説明(<コマンド名>.summary)
13
+ def summary = Messages.t(:"#{command_name}.summary")
14
+
15
+ def command_name = name.split("::").last.downcase
16
+ end
17
+
18
+ def initialize(context)
19
+ @context = context
20
+ end
21
+
22
+ private
23
+
24
+ def out = @context.out
25
+ def client = @context.client
26
+
27
+ # オプションを解析する。--help を表示した場合はnilを返す
28
+ def parse_options(args)
29
+ parser = OptionParser.new
30
+ parser.banner = t(:"#{self.class.command_name}.usage")
31
+ yield parser if block_given?
32
+ parser.on("-h", "--help", t(:"common.help_option")) do
33
+ out.print parser.help
34
+ return nil
35
+ end
36
+ parser.parse!(args)
37
+ args
38
+ rescue OptionParser::ParseError => e
39
+ raise Error, t(:"common.invalid_option", message: e.message)
40
+ end
41
+
42
+ # 引数を取らないコマンドで、引数が指定されていないことを確認する
43
+ def check_no_arguments!(args, command = self.class.command_name)
44
+ raise Error, t(:"common.no_arguments", command: command, args: args.join(" ")) unless args.empty?
45
+ end
46
+
47
+ # 手順の案内を、1行ずつ「→」を付けて表示する
48
+ def print_guide(guide)
49
+ guide.each_line(chomp: true) { |line| out.puts " → #{line}" }
50
+ end
51
+
52
+ def validate_path!(path)
53
+ segments = path.split("/")
54
+ return unless path.empty? || path.start_with?("/") || segments.include?("..")
55
+
56
+ raise Error, t(:"common.invalid_path", path: path)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moonraker
4
+ module Bosun
5
+ module Commands
6
+ class Diff < Base
7
+ def run(args)
8
+ stat = false
9
+ args = parse_options(args) do |o|
10
+ o.on("--stat", t(:"diff.stat_option")) { stat = true }
11
+ end
12
+ return 0 unless args
13
+
14
+ args.each { |path| validate_path!(path) }
15
+ git = @context.git
16
+ tip = HostTracking.new(git).tip
17
+ raise Error, t(:"common.no_tracked_files") unless tip
18
+
19
+ paths = args.empty? ? [HostTracking::CONFIG_DIR] : args.map { |path| HostTracking.repo_path(path) }
20
+ options = ["--no-ext-diff", "--no-renames"]
21
+ options << "--stat" if stat
22
+ options << "--color=always" if out.respond_to?(:tty?) && out.tty?
23
+ out.print git.run("diff", *options, HostTracking::BRANCH, "HEAD", "--", *paths)
24
+ 0
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+
6
+ module Moonraker
7
+ module Bosun
8
+ module Commands
9
+ class Export < Base
10
+ def run(args)
11
+ at = nil
12
+ rev = nil
13
+ output = nil
14
+ args = parse_options(args) do |o|
15
+ o.on("--at DATETIME", t(:"export.at_option")) { |v| at = v }
16
+ o.on("--rev REV", t(:"export.rev_option")) { |v| rev = v }
17
+ o.on("-o", "--output DIR", t(:"export.output_option")) { |v| output = v }
18
+ end
19
+ return 0 unless args
20
+ raise Error, t(:"export.at_and_rev") if at && rev
21
+
22
+ git = @context.git
23
+ tracking = HostTracking.new(git)
24
+ raise Error, t(:"export.no_branch", branch: HostTracking::BRANCH) unless tracking.tip
25
+
26
+ commit = if at
27
+ commit_at(git, at)
28
+ elsif rev
29
+ commit_of_rev(git, rev)
30
+ else
31
+ tracking.tip
32
+ end
33
+ tree = git.ls_tree(commit)
34
+ paths = args.empty? ? tree.keys.sort : args.map { |arg| resolve_path(tree, arg) }.uniq
35
+
36
+ dir = File.expand_path(output || default_dir(git, commit), @context.cwd)
37
+ raise Error, t(:"export.exists", dir: dir) if File.exist?(dir) || File.symlink?(dir)
38
+
39
+ write(git, tree, paths, dir)
40
+ print_result(git, commit, paths, dir)
41
+ 0
42
+ end
43
+
44
+ private
45
+
46
+ def commit_at(git, at)
47
+ time = begin
48
+ Time.parse(at)
49
+ rescue ArgumentError
50
+ raise Error, t(:"export.invalid_time", at: at)
51
+ end
52
+ commit = git.run("rev-list", "-1", "--before=#{time.iso8601}", HostTracking::REF).strip
53
+ raise Error, t(:"export.none_before", time: time.strftime("%Y-%m-%d %H:%M:%S")) if commit.empty?
54
+
55
+ commit
56
+ end
57
+
58
+ # host-trackingは一直線の履歴なので、指定したコミットとの共通の祖先が、
59
+ # そのコミットの時点で取り込んでいた最新の取得の時点になる
60
+ def commit_of_rev(git, rev)
61
+ commit = git.rev_parse(rev)
62
+ raise Error, t(:"export.no_commit", rev: rev) unless commit
63
+
64
+ base = git.merge_base(commit, HostTracking::REF)
65
+ raise Error, t(:"export.not_merged", rev: rev, branch: HostTracking::BRANCH) unless base
66
+
67
+ base
68
+ end
69
+
70
+ # Root configからのパスと、リポジトリ上のパスのどちらでも指定できる
71
+ def resolve_path(tree, arg)
72
+ validate_path!(arg)
73
+ [HostTracking.repo_path(arg), arg].find { |path| tree.key?(path) } or
74
+ raise Error, t(:"export.not_in_snapshot", path: arg)
75
+ end
76
+
77
+ def default_dir(git, commit)
78
+ date = git.run("log", "-1", "--format=%cd", "--date=format-local:%Y%m%d-%H%M%S", commit).strip
79
+ "export-#{date}-#{commit[0, 7]}"
80
+ end
81
+
82
+ def write(git, tree, paths, dir)
83
+ FileUtils.mkdir_p(dir)
84
+ paths.each do |path|
85
+ dest = File.join(dir, path)
86
+ FileUtils.mkdir_p(File.dirname(dest))
87
+ File.open(dest, File::WRONLY | File::CREAT | File::EXCL | File::BINARY) do |f|
88
+ f.write(git.cat_blob(tree.fetch(path)))
89
+ end
90
+ end
91
+ end
92
+
93
+ def print_result(git, commit, paths, dir)
94
+ info = git.run("log", "-1", "--format=%cd%x00%s", "--date=format-local:%Y-%m-%d %H:%M:%S", commit).strip
95
+ date, subject = info.split("\0", 2)
96
+ out.puts "#{HostTracking::BRANCH}: #{commit[0, 7]} (#{date}) #{subject}"
97
+ out.puts t(:"export.written", dir: dir)
98
+ paths.each { |path| out.puts " #{path}" }
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moonraker
4
+ module Bosun
5
+ module Commands
6
+ # ホストから取得してhost-trackingにコミットし、現在のブランチにマージする処理
7
+ module Fetching
8
+ private
9
+
10
+ def tracking
11
+ @tracking ||= HostTracking.new(@context.git)
12
+ end
13
+
14
+ def list_config_files
15
+ client.list_files(FileRules::CONFIG_ROOT).to_h { |info| [info.path, info] }
16
+ end
17
+
18
+ # pathsはRoot configからのパス
19
+ def fetch(paths, infos)
20
+ paths.map do |path|
21
+ fetched = HostTracking::Fetched.new(
22
+ repo_path: HostTracking.repo_path(path),
23
+ content: client.download(FileRules::CONFIG_ROOT, path),
24
+ info: infos.fetch(path)
25
+ )
26
+ out.puts t(:"common.fetched", path: fetched.repo_path)
27
+ fetched
28
+ end
29
+ end
30
+
31
+ # removedはリポジトリ上のパス
32
+ def commit_and_merge(op, fetched, removed: [])
33
+ parent = tracking.tip
34
+ commit = fetched.empty? && removed.empty? ? nil : tracking.commit(op, fetched, removed: removed)
35
+ if commit
36
+ changed = parent ? @context.git.diff_paths(parent, commit) : fetched.map(&:repo_path).sort
37
+ out.puts t(:"tracking.committed", branch: HostTracking::BRANCH, commit: commit[0, 7])
38
+ changed.each do |path|
39
+ out.puts t(removed.include?(path) ? :"tracking.removed" : :"tracking.changed", path: path)
40
+ end
41
+ else
42
+ changed = []
43
+ out.puts t(:"tracking.no_change", branch: HostTracking::BRANCH)
44
+ end
45
+ merge(op, changed)
46
+ end
47
+
48
+ def check_not_printing!
49
+ state = client.print_state
50
+ return unless %w[printing paused].include?(state)
51
+
52
+ raise Error, t(:"common.printing", state: state)
53
+ end
54
+
55
+ # トラッキング中のファイル(Root configからのパス)
56
+ def tracked_paths
57
+ tip = tracking.tip
58
+ raise Error, t(:"common.no_tracked_files") unless tip
59
+
60
+ @context.git.ls_tree_paths(tip, HostTracking::CONFIG_DIR).map { |path| HostTracking.host_path(path) }
61
+ end
62
+
63
+ def merge(op, changed)
64
+ detail = changed.empty? ? op : "#{op}: #{changed.join(', ')}"
65
+ # gitに記録するメッセージなので、表示の言語によらず英語にする
66
+ return unless tracking.merge("Merge #{HostTracking::BRANCH} (#{detail})")
67
+
68
+ out.puts t(:"tracking.merged", branch: @context.git.current_branch)
69
+ rescue HostTracking::MergeError => e
70
+ @context.err.puts "bosun: #{e.message}"
71
+ raise Error, t(:"tracking.merge_incomplete")
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moonraker
4
+ module Bosun
5
+ module Commands
6
+ class Forget < Base
7
+ include Fetching
8
+ include Removing
9
+
10
+ def run(args)
11
+ args = parse_options(args)
12
+ return 0 unless args
13
+ raise Error, t(:"forget.no_files") if args.empty?
14
+
15
+ paths = args.uniq
16
+ check_removable!(paths)
17
+
18
+ commit_and_merge("forget", [], removed: paths.map { |path| HostTracking.repo_path(path) })
19
+ out.puts
20
+ out.puts t(:"forget.kept")
21
+ 0
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end