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,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Moonraker
6
+ module Bosun
7
+ # gitコマンドの実行
8
+ class Git
9
+ # パスにglobなどを含んでいても、そのまま扱わせる
10
+ BASE_ENV = { "GIT_LITERAL_PATHSPECS" => "1" }.freeze
11
+
12
+ Result = Data.define(:stdout, :stderr, :status) do
13
+ def success? = status.success?
14
+ end
15
+
16
+ def self.open(dir)
17
+ stdout, _, status = Open3.capture3(BASE_ENV, "git", "-C", dir, "rev-parse", "--show-toplevel")
18
+ raise Error, Messages.t(:"git.not_repository", dir: dir) unless status.success?
19
+
20
+ new(stdout.chomp)
21
+ end
22
+
23
+ attr_reader :root
24
+
25
+ def initialize(root)
26
+ @root = root
27
+ end
28
+
29
+ # 失敗した場合はErrorを発生させ、標準出力を返す
30
+ def run(*args, stdin: nil, env: {})
31
+ result = capture(*args, stdin: stdin, env: env)
32
+ raise Error, Messages.t(:"git.failed", command: args.first, message: result.stderr.strip) unless result.success?
33
+
34
+ result.stdout
35
+ end
36
+
37
+ # 失敗しても例外を発生させず、Resultを返す
38
+ def capture(*, stdin: nil, env: {})
39
+ stdout, stderr, status = Open3.capture3(
40
+ BASE_ENV.merge(env), "git", "-C", @root, *,
41
+ stdin_data: stdin.to_s, binmode: true
42
+ )
43
+ Result.new(stdout: utf8(stdout), stderr: utf8(stderr), status: status)
44
+ end
45
+
46
+ # コミットを指すrevならSHA-1を返し、なければnilを返す
47
+ def rev_parse(rev)
48
+ result = capture("rev-parse", "--verify", "--quiet", "#{rev}^{commit}")
49
+ result.success? ? result.stdout.strip : nil
50
+ end
51
+
52
+ def tree_of(rev) = run("rev-parse", "#{rev}^{tree}").strip
53
+
54
+ # 現在のブランチ名。detached HEADならnil
55
+ def current_branch
56
+ result = capture("symbolic-ref", "--quiet", "--short", "HEAD")
57
+ result.success? ? result.stdout.strip : nil
58
+ end
59
+
60
+ def merge_in_progress? = !rev_parse("MERGE_HEAD").nil?
61
+
62
+ # indexにHEADからの変更(ステージ済みの変更)があるか
63
+ def staged_changes?
64
+ !capture("diff", "--cached", "--quiet").success?
65
+ end
66
+
67
+ def ancestor?(ancestor, descendant)
68
+ capture("merge-base", "--is-ancestor", ancestor, descendant).success?
69
+ end
70
+
71
+ def merge_base(a, b)
72
+ result = capture("merge-base", a, b)
73
+ result.success? ? result.stdout.strip : nil
74
+ end
75
+
76
+ # ファイルの内容をそのままblobとして書き込む(フィルタは適用しない)
77
+ def hash_object(content, write: true)
78
+ args = ["hash-object", "--no-filters", "--stdin"]
79
+ args.insert(1, "-w") if write
80
+ run(*args, stdin: content).strip
81
+ end
82
+
83
+ # パスとblobのSHA-1の対応
84
+ def ls_tree(rev, *paths)
85
+ run("ls-tree", "-r", "-z", rev, "--", *paths).split("\0").to_h do |entry|
86
+ meta, path = entry.split("\t", 2)
87
+ [path, meta.split[2]]
88
+ end
89
+ end
90
+
91
+ # パスとモード(100644、100755、120000など)の対応
92
+ def ls_tree_modes(rev, *paths)
93
+ run("ls-tree", "-r", "-z", rev, "--", *paths).split("\0").to_h do |entry|
94
+ meta, path = entry.split("\t", 2)
95
+ [path, meta.split[0]]
96
+ end
97
+ end
98
+
99
+ def blob_size(blob) = run("cat-file", "-s", blob).to_i
100
+ def cat_blob(blob) = run("cat-file", "blob", blob).b
101
+
102
+ # パスと[blobのSHA-1, サイズ]の対応
103
+ def ls_tree_with_size(rev)
104
+ run("ls-tree", "-r", "-z", "-l", rev).split("\0").to_h do |entry|
105
+ meta, path = entry.split("\t", 2)
106
+ _mode, _type, blob, size = meta.split
107
+ [path, [blob, size.to_i]]
108
+ end
109
+ end
110
+
111
+ def ls_tree_paths(rev, *paths)
112
+ run("ls-tree", "-r", "-z", "--name-only", rev, "--", *paths).split("\0")
113
+ end
114
+
115
+ def diff_paths(from, to)
116
+ run("diff", "--name-only", "-z", "--no-renames", from, to).split("\0")
117
+ end
118
+
119
+ # 作業ツリーやindexに変更があるパス(git管理外のファイルを含む)
120
+ def dirty_paths(paths)
121
+ return [] if paths.empty?
122
+
123
+ entries = run("status", "--porcelain=v1", "-z", "--untracked-files=all", "--", *paths).split("\0")
124
+ paths = []
125
+ until entries.empty?
126
+ entry = entries.shift
127
+ paths << entry[3..]
128
+ # 名前の変更とコピーは、元のパスが続く
129
+ paths << entries.shift if entry.start_with?("R", "C")
130
+ end
131
+ paths
132
+ end
133
+
134
+ private
135
+
136
+ def utf8(string) = string.force_encoding(Encoding::UTF_8)
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module Moonraker
6
+ module Bosun
7
+ # host-trackingブランチの操作
8
+ #
9
+ # docs/design.md を参照。
10
+ class HostTracking
11
+ include Messages
12
+
13
+ BRANCH = "host-tracking"
14
+ REF = "refs/heads/#{BRANCH}".freeze
15
+
16
+ # Moonrakerのroot configに対応するリポジトリのディレクトリ
17
+ CONFIG_DIR = "config"
18
+ # Moonraker DBのバックアップを置くディレクトリ
19
+ DB_BACKUP_DIR = "backup/moonraker-db"
20
+
21
+ # ホストから取得したファイル
22
+ #
23
+ # infoはファイル一覧の情報(Client::FileInfo)。Moonraker DBのバックアップの場合はnil
24
+ Fetched = Data.define(:repo_path, :content, :info)
25
+
26
+ class MergeError < Error; end
27
+
28
+ ZERO_OID = "0" * 40
29
+
30
+ def self.repo_path(host_path) = "#{CONFIG_DIR}/#{host_path}"
31
+ def self.host_path(repo_path) = repo_path.delete_prefix("#{CONFIG_DIR}/")
32
+
33
+ # コミットメッセージに記録した、取得時のホスト側の値
34
+ Recorded = Data.define(:modified, :size)
35
+
36
+ HOST_FILE = /^Host-File: (?<path>.+) modified=(?<modified>\S+) size=(?<size>\d+)$/
37
+ HOST_FILE_DELETED = /^Host-File: (?<path>.+) deleted$/
38
+ HOST_DB = /^Host-DB: (?<path>.+)$/
39
+
40
+ def initialize(git)
41
+ @git = git
42
+ end
43
+
44
+ def tip = @git.rev_parse(REF)
45
+
46
+ # host-trackingへのコミットや、そのマージを行える状態かを確認する
47
+ #
48
+ # paths: 今回取得するファイルのリポジトリ上のパス
49
+ def check_workspace!(paths)
50
+ branch = @git.current_branch
51
+ raise Error, t(:"tracking.detached_head") unless branch
52
+ raise Error, t(:"tracking.on_tracking_branch", branch: BRANCH) if branch == BRANCH
53
+ raise Error, t(:"tracking.no_head") unless @git.rev_parse("HEAD")
54
+ raise Error, t(:"tracking.merge_in_progress") if @git.merge_in_progress?
55
+ # git mergeは、indexに変更があると対象のファイルと関係がなくても中止する
56
+ raise Error, t(:"tracking.staged_changes") if @git.staged_changes?
57
+
58
+ dirty = @git.dirty_paths((paths + unmerged_paths).uniq)
59
+ return if dirty.empty?
60
+
61
+ raise Error, t(:"tracking.dirty", paths: dirty.map { |path| " #{path}" }.join("\n"))
62
+ end
63
+
64
+ OPS = %w[add pull push backup forget rm].freeze
65
+ TOP_DIRS = %w[config backup].freeze
66
+
67
+ # 不変条件(docs/design.md)を検査し、違反の説明の配列を返す
68
+ def check_invariants
69
+ commit = tip
70
+ return [] unless commit
71
+
72
+ @git.run("rev-list", "--parents", commit).lines(chomp: true).flat_map do |line|
73
+ sha, *parents = line.split
74
+ check_commit(sha, parents)
75
+ end
76
+ end
77
+
78
+ # 取得したファイルと、除くファイル(リポジトリ上のパス)をhost-trackingにコミットする
79
+ #
80
+ # 作業ツリーとindexは使わない。ツリーが変わらない場合はコミットせずにnilを返す。
81
+ def commit(op, fetched, removed: [])
82
+ parent = tip
83
+ Dir.mktmpdir("bosun-index") do |dir|
84
+ env = { "GIT_INDEX_FILE" => File.join(dir, "index") }
85
+ @git.run("read-tree", parent, env: env) if parent
86
+ entries = fetched.map { |f| "100644 #{@git.hash_object(f.content)}\t#{f.repo_path}\0" }
87
+ # モードが0の行は、indexからそのパスを除く
88
+ entries += removed.map { |path| "0 #{ZERO_OID}\t#{path}\0" }
89
+ @git.run("update-index", "-z", "--index-info", stdin: entries.join, env: env)
90
+ tree = @git.run("write-tree", env: env).strip
91
+ return nil if parent && tree == @git.tree_of(parent)
92
+
93
+ args = ["commit-tree", tree, "-F", "-"]
94
+ args += ["-p", parent] if parent
95
+ commit = @git.run(*args, stdin: message(op, fetched, removed)).strip
96
+ # 途中でhost-trackingが変わっていた場合は失敗させる(初回は存在しないことを確認する)
97
+ @git.run("update-ref", "-m", "bosun #{op}", REF, commit, parent || "")
98
+ commit
99
+ end
100
+ end
101
+
102
+ # host-trackingを現在のブランチにマージする
103
+ #
104
+ # マージした場合はtrue、すでに取り込まれている場合はfalseを返す。
105
+ def merge(message)
106
+ commit = tip
107
+ return false if commit.nil? || @git.ancestor?(commit, "HEAD")
108
+
109
+ # fast-forwardすると現在のブランチのツリーがhost-trackingのものになり、コードが消えるので必ず禁止する
110
+ args = ["merge", "--no-ff", "--no-edit", "-m", message]
111
+ args << "--allow-unrelated-histories" unless @git.merge_base("HEAD", commit)
112
+ result = @git.capture(*args, BRANCH)
113
+ return true if result.success?
114
+
115
+ if @git.merge_in_progress?
116
+ raise MergeError, t(:"tracking.merge_conflict", branch: BRANCH, output: result.stdout.strip)
117
+ end
118
+
119
+ raise MergeError, t(:"tracking.merge_failed", branch: BRANCH, output: result.stderr.strip)
120
+ end
121
+
122
+ # ファイルごとに、最後に記録したRecordedを返す(リポジトリ上のパスがキー)
123
+ def recorded
124
+ commit = tip
125
+ return {} unless commit
126
+
127
+ result = {}
128
+ seen = {}
129
+ # 新しいコミットから順に見て、最初に見つかった記録を使う
130
+ @git.run("log", "--format=%B%x00", commit).split("\0").each do |body|
131
+ body.each_line(chomp: true) do |line|
132
+ if (m = HOST_FILE.match(line))
133
+ next if seen[m[:path]]
134
+
135
+ seen[m[:path]] = true
136
+ result[m[:path]] = Recorded.new(modified: Float(m[:modified]), size: Integer(m[:size]))
137
+ elsif (m = HOST_FILE_DELETED.match(line))
138
+ seen[m[:path]] = true
139
+ end
140
+ end
141
+ end
142
+ result
143
+ end
144
+
145
+ private
146
+
147
+ def check_commit(sha, parents)
148
+ short = sha[0, 7]
149
+ problems = []
150
+ problems << t(:"invariant.merge_commit", commit: short, count: parents.size) if parents.size > 1
151
+
152
+ body = @git.run("log", "-1", "--format=%B", sha)
153
+ op = body[/^Host-Op: (.+)$/, 1]
154
+ if op.nil?
155
+ problems << t(:"invariant.no_op", commit: short)
156
+ elsif !OPS.include?(op)
157
+ problems << t(:"invariant.unknown_op", commit: short, op: op)
158
+ end
159
+
160
+ tree = @git.ls_tree_with_size(sha)
161
+ outside = tree.keys.reject { |path| TOP_DIRS.any? { |dir| path.start_with?("#{dir}/") } }
162
+ unless outside.empty?
163
+ dirs = TOP_DIRS.map { |dir| "#{dir}/" }.join(t(:"invariant.dir_separator"))
164
+ more = outside.size > 3 ? t(:"invariant.more", count: outside.size - 3) : ""
165
+ problems << t(:"invariant.outside", commit: short, dirs: dirs, paths: "#{outside.first(3).join(', ')}#{more}")
166
+ end
167
+
168
+ body.each_line(chomp: true) do |line|
169
+ if (m = HOST_DB.match(line))
170
+ problems << t(:"invariant.db_missing", commit: short, path: m[:path]) unless tree.key?(m[:path])
171
+ next
172
+ end
173
+ if (m = HOST_FILE_DELETED.match(line))
174
+ problems << t(:"invariant.deleted_present", commit: short, path: m[:path]) if tree.key?(m[:path])
175
+ next
176
+ end
177
+ next unless (m = HOST_FILE.match(line))
178
+
179
+ entry = tree[m[:path]]
180
+ if entry.nil?
181
+ problems << t(:"invariant.file_missing", commit: short, path: m[:path])
182
+ elsif entry[1] != Integer(m[:size])
183
+ problems << t(:"invariant.size_mismatch", commit: short, recorded: m[:size], actual: entry[1],
184
+ path: m[:path])
185
+ end
186
+ end
187
+ problems
188
+ end
189
+
190
+ # host-trackingにあって)、まだ現在のブランチに取り込んでいない変更のパス
191
+ def unmerged_paths
192
+ commit = tip
193
+ return [] unless commit
194
+
195
+ base = @git.merge_base("HEAD", commit)
196
+ base ? @git.diff_paths(base, commit) : @git.ls_tree_paths(commit)
197
+ end
198
+
199
+ def message(op, fetched, removed)
200
+ count = fetched.size + removed.size
201
+ lines = ["#{op}: #{count} #{count == 1 ? 'file' : 'files'}", "", "Host-Op: #{op}"]
202
+ fetched.each do |f|
203
+ lines << if f.info
204
+ "Host-File: #{f.repo_path} modified=#{f.info.modified} size=#{f.info.size}"
205
+ else
206
+ "Host-DB: #{f.repo_path}"
207
+ end
208
+ end
209
+ removed.each { |path| lines << "Host-File: #{path} deleted" }
210
+ "#{lines.join("\n")}\n"
211
+ end
212
+ end
213
+ end
214
+ end