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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE +21 -0
- data/README.ja.md +467 -0
- data/README.md +124 -0
- data/docs/design.md +358 -0
- data/docs/moonraker-api.md +108 -0
- data/exe/bosun +7 -0
- data/lib/moonraker/bosun/cli.rb +64 -0
- data/lib/moonraker/bosun/client.rb +147 -0
- data/lib/moonraker/bosun/commands/add.rb +38 -0
- data/lib/moonraker/bosun/commands/audit.rb +48 -0
- data/lib/moonraker/bosun/commands/backup.rb +135 -0
- data/lib/moonraker/bosun/commands/base.rb +61 -0
- data/lib/moonraker/bosun/commands/diff.rb +29 -0
- data/lib/moonraker/bosun/commands/export.rb +103 -0
- data/lib/moonraker/bosun/commands/fetching.rb +76 -0
- data/lib/moonraker/bosun/commands/forget.rb +26 -0
- data/lib/moonraker/bosun/commands/get.rb +57 -0
- data/lib/moonraker/bosun/commands/ls.rb +43 -0
- data/lib/moonraker/bosun/commands/pull.rb +52 -0
- data/lib/moonraker/bosun/commands/push.rb +136 -0
- data/lib/moonraker/bosun/commands/removing.rb +38 -0
- data/lib/moonraker/bosun/commands/rm.rb +105 -0
- data/lib/moonraker/bosun/commands/roots.rb +31 -0
- data/lib/moonraker/bosun/commands/status.rb +22 -0
- data/lib/moonraker/bosun/commands/status_report.rb +75 -0
- data/lib/moonraker/bosun/config.rb +31 -0
- data/lib/moonraker/bosun/context.rb +26 -0
- data/lib/moonraker/bosun/error.rb +8 -0
- data/lib/moonraker/bosun/file_rules.rb +37 -0
- data/lib/moonraker/bosun/git.rb +139 -0
- data/lib/moonraker/bosun/host_tracking.rb +214 -0
- data/lib/moonraker/bosun/messages/catalog.rb +837 -0
- data/lib/moonraker/bosun/messages.rb +42 -0
- data/lib/moonraker/bosun/push_plan.rb +89 -0
- data/lib/moonraker/bosun/sync_status.rb +130 -0
- data/lib/moonraker/bosun/version.rb +7 -0
- data/lib/moonraker/bosun.rb +31 -0
- 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
|