samagotchi 0.149.0 → 0.151.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 +4 -4
- data/CHANGELOG.md +69 -1
- data/README.md +4 -2
- data/docs/architecture.md +4 -4
- data/docs/bundle-authoring.md +1 -1
- data/docs/cli.md +59 -24
- data/docs/configuration.md +6 -6
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/block-renderers.md +96 -0
- data/docs/internals/bridge.md +53 -9
- data/docs/internals/bundles.md +43 -14
- data/docs/internals/objectives.md +7 -2
- data/docs/internals/web-frontend.md +2 -2
- data/docs/internals/web-push.md +2 -2
- data/docs/internals/working-set.md +5 -2
- data/docs/memory.md +48 -10
- data/docs/plugins.md +49 -3
- data/docs/sessions.md +5 -4
- data/docs/testing.md +5 -0
- data/lib/samagotchi/atomic_file.rb +3 -0
- data/lib/samagotchi/block_renderers.rb +115 -0
- data/lib/samagotchi/bootstrap_command.rb +5 -2
- data/lib/samagotchi/bridge/card_places.rb +93 -0
- data/lib/samagotchi/bridge/card_store.rb +224 -82
- data/lib/samagotchi/bridge.rb +12 -4
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +91 -68
- data/lib/samagotchi/bundler_env.rb +24 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -1
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -1
- data/lib/samagotchi/bundles/known-names/manifest.yml +3 -2
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +2 -2
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/chi_home.rb +4 -2
- data/lib/samagotchi/chi_shim.rb +2 -1
- data/lib/samagotchi/child_env.rb +10 -3
- data/lib/samagotchi/engine.rb +19 -1
- data/lib/samagotchi/git_env.rb +43 -0
- data/lib/samagotchi/guardrails/context.rb +2 -0
- data/lib/samagotchi/guardrails/targets.rb +4 -1
- data/lib/samagotchi/memory_bundle/asset_installer.rb +23 -3
- data/lib/samagotchi/memory_bundle/builder.rb +5 -3
- data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +70 -6
- data/lib/samagotchi/memory_bundle/installed_copy.rb +1 -0
- data/lib/samagotchi/memory_bundle/installer.rb +162 -52
- data/lib/samagotchi/memory_bundle/listing.rb +4 -3
- data/lib/samagotchi/memory_bundle/manifest.rb +23 -2
- data/lib/samagotchi/memory_bundle/merger.rb +209 -8
- data/lib/samagotchi/memory_bundle/placeholder.rb +2 -1
- data/lib/samagotchi/memory_bundle/profile.rb +7 -3
- data/lib/samagotchi/memory_bundle/provenance.rb +182 -22
- data/lib/samagotchi/memory_bundle/remerge.rb +167 -0
- data/lib/samagotchi/memory_bundle/shipped_history.rb +79 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +89 -24
- data/lib/samagotchi/memory_bundle/source.rb +3 -3
- data/lib/samagotchi/memory_bundle/status.rb +21 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +98 -33
- data/lib/samagotchi/memory_bundle/text_merge.rb +399 -0
- data/lib/samagotchi/memory_bundle/trash.rb +58 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +26 -6
- data/lib/samagotchi/objectives_check.rb +37 -7
- data/lib/samagotchi/project_env.rb +3 -2
- data/lib/samagotchi/self_report.rb +2 -0
- data/lib/samagotchi/session_chain.rb +2 -7
- data/lib/samagotchi/session_commands.rb +15 -9
- data/lib/samagotchi/session_manager.rb +10 -7
- data/lib/samagotchi/steer.rb +17 -0
- data/lib/samagotchi/terminal_ui.rb +4 -4
- data/lib/samagotchi/testing/boundary.rb +1 -0
- data/lib/samagotchi/testing/minitest.rb +3 -0
- data/lib/samagotchi/testing/rspec.rb +3 -0
- data/lib/samagotchi/testing/sandbox.rb +3 -1
- data/lib/samagotchi/tools/delegate.rb +2 -1
- data/lib/samagotchi/tools/task_runtime.rb +13 -10
- data/lib/samagotchi/turn_checkpoint.rb +11 -5
- data/lib/samagotchi/turn_flow.rb +2 -1
- data/lib/samagotchi/update_command.rb +76 -28
- data/lib/samagotchi/update_hint.rb +4 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +22 -12
- data/lib/samagotchi/web/auto_update.rb +8 -8
- data/lib/samagotchi/web/auto_update_record.rb +6 -4
- data/lib/samagotchi/web/block_renderer_routes.rb +32 -0
- data/lib/samagotchi/web/markdown_renderer.rb +39 -6
- data/lib/samagotchi/web/message_display.rb +3 -3
- data/lib/samagotchi/web/public/app.js +7 -3
- data/lib/samagotchi/web/public/block_frame.js +202 -0
- data/lib/samagotchi/web/public/block_host.js +502 -0
- data/lib/samagotchi/web/public/block_open.js +13 -0
- data/lib/samagotchi/web/public/block_renderers.css +20 -0
- data/lib/samagotchi/web/public/block_source.js +37 -0
- data/lib/samagotchi/web/public/bubbles.js +11 -5
- data/lib/samagotchi/web/public/card.js +17 -10
- data/lib/samagotchi/web/public/cards_place.js +10 -5
- data/lib/samagotchi/web/public/claim_link.js +4 -2
- data/lib/samagotchi/web/public/details_motion.js +2 -1
- data/lib/samagotchi/web/public/history_reveal.js +4 -1
- data/lib/samagotchi/web/public/index.html +20 -14
- data/lib/samagotchi/web/public/markdown_place.js +2 -3
- data/lib/samagotchi/web/public/stage_model.js +5 -6
- data/lib/samagotchi/web/public/stage_view.js +8 -7
- data/lib/samagotchi/web/public/text_roots.js +6 -0
- data/lib/samagotchi/web/public/turn_block.js +34 -19
- data/lib/samagotchi/web/public/turn_html.js +4 -1
- data/lib/samagotchi/web/public/turn_model.js +1 -1
- data/lib/samagotchi/web/public/update_sheet.js +8 -3
- data/lib/samagotchi/web/public/working_set_view.js +10 -7
- data/lib/samagotchi/web/push/notifier.rb +7 -9
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_show.rb +1 -1
- data/lib/samagotchi/web/update_ops.rb +2 -1
- data/lib/samagotchi/worker.rb +4 -2
- metadata +17 -1
|
@@ -1,32 +1,162 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
|
+
require_relative "../atomic_file"
|
|
5
|
+
require_relative "installed_bundle"
|
|
4
6
|
|
|
5
7
|
module Samagotchi
|
|
6
8
|
module MemoryBundle
|
|
9
|
+
# Most processes that load this never merge.
|
|
10
|
+
autoload :TextMerge, File.expand_path("text_merge", __dir__)
|
|
11
|
+
autoload :ShippedHistory, File.expand_path("shipped_history", __dir__)
|
|
12
|
+
|
|
7
13
|
# 3-way merge helper for bundle upgrades.
|
|
8
14
|
#
|
|
9
15
|
# Compare base (provenance snapshot), current (on-disk), incoming (new bundle)
|
|
10
16
|
# and classify per file. Used by Installer#run when provenance exists.
|
|
11
17
|
module Merger
|
|
18
|
+
# What an upgrade does to one file. status names the case; base is where
|
|
19
|
+
# the file's next base comes from:
|
|
20
|
+
# :on_disk the file as the install leaves it: it took the bundle's
|
|
21
|
+
# bytes, or already had them.
|
|
22
|
+
# :recorded the base it has (none stays none): the file stays as the
|
|
23
|
+
# user has it, and the next upgrade must still see the edit.
|
|
24
|
+
# :incoming the bundle's new file: a merged file keeps the user's
|
|
25
|
+
# edits, so it must never become the base (the edit is what
|
|
26
|
+
# differs from the base, and the next upgrade would
|
|
27
|
+
# fast-forward over it).
|
|
28
|
+
# Installer#next_base reads it. A new outcome and its base go here; it
|
|
29
|
+
# also needs a branch in Installer#merge_memory (which raises without
|
|
30
|
+
# one), #next_base if its base is a new kind, and its status word in
|
|
31
|
+
# Installer#preview_memory, #verify_memory_checksums' list and #summary.
|
|
32
|
+
#
|
|
33
|
+
# merge is Upgrade#outcome's TextMerge::Result, so a merge runs git once
|
|
34
|
+
# and its answer travels with the outcome: a :merged outcome's text, a
|
|
35
|
+
# :conflict's why (nil for a real one). current_sha is the digest of the
|
|
36
|
+
# file that merge read: write_merged checks it. .classify leaves both nil.
|
|
37
|
+
Outcome = Data.define(:status, :base, :merge, :current_sha) do
|
|
38
|
+
def initialize(status:, base:, merge: nil, current_sha: nil) = super
|
|
39
|
+
|
|
40
|
+
def conflict? = status == :conflict
|
|
41
|
+
|
|
42
|
+
def merged? = status == :merged
|
|
43
|
+
|
|
44
|
+
# Why no merge was possible, nil when one wasn't tried or it found a
|
|
45
|
+
# real conflict.
|
|
46
|
+
def why = merge&.why
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
INSTALL = Outcome.new(status: :install, base: :on_disk)
|
|
50
|
+
FAST_FORWARD = Outcome.new(status: :fast_forward, base: :on_disk)
|
|
51
|
+
NOOP = Outcome.new(status: :noop, base: :on_disk)
|
|
52
|
+
KEEP = Outcome.new(status: :keep, base: :recorded)
|
|
53
|
+
CONFLICT = Outcome.new(status: :conflict, base: :recorded)
|
|
54
|
+
# Nothing to take: the bundle has no such file, so whatever is on disk
|
|
55
|
+
# (the user's text, perhaps) must not become the base. A noop to callers.
|
|
56
|
+
NO_INCOMING = Outcome.new(status: :noop, base: :recorded)
|
|
57
|
+
# Both changed it, and the text merge adds nothing: the user's file already
|
|
58
|
+
# has what the bundle brings. Kept, but the base moves on. (Upgrade#outcome)
|
|
59
|
+
ALREADY_MERGED = Outcome.new(status: :keep, base: :incoming)
|
|
60
|
+
|
|
61
|
+
# What an upgrade would do to the files asked about, by what callers say
|
|
62
|
+
# of them. merged: file names; behind: FileStanding::Behind.
|
|
63
|
+
Forecast = Data.define(:merged, :behind) do
|
|
64
|
+
def conflicts = behind.map(&:file)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# One bundle's upgrade as seen before it runs: what it does to a file,
|
|
68
|
+
# nothing written. The one way to ask: Installer's merge and dry run,
|
|
69
|
+
# ShippedUpdate's plan and SystemBundle's dry run. Which files to ask
|
|
70
|
+
# about is the caller's, and they differ (see #forecast).
|
|
71
|
+
Upgrade = Data.define(:provenance, :target_dir, :source_dir) do
|
|
72
|
+
# What the upgrade does to the file: .classify, then, where both sides
|
|
73
|
+
# changed it, the text merge (git runs here, once per call). A mark from
|
|
74
|
+
# an older chi fast-forwards only if no line of the file goes or its base
|
|
75
|
+
# is proved: see .merge_outcome.
|
|
76
|
+
# @return [Outcome]
|
|
77
|
+
def outcome(file_key)
|
|
78
|
+
classified = classified(file_key)
|
|
79
|
+
return classified unless classified.conflict? || classified == FAST_FORWARD
|
|
80
|
+
|
|
81
|
+
legacy = InstalledBundle.legacy_mark?(provenance.record&.files&.fetch(file_key, nil))
|
|
82
|
+
# Asked only when the guard would otherwise fire.
|
|
83
|
+
unproved = -> { legacy && !ShippedHistory.shipped?(provenance.name, file_key, paths(file_key)[:base]) }
|
|
84
|
+
return Merger.merge_outcome(paths(file_key), unproved: unproved) if classified.conflict?
|
|
85
|
+
|
|
86
|
+
# The guard's own case at its plainest: the file is the base, which
|
|
87
|
+
# may be the user's text. One that only gains lines loses nothing, and
|
|
88
|
+
# save_marked_file keeps a copy.
|
|
89
|
+
sides = paths(file_key)
|
|
90
|
+
return classified unless legacy && Merger.removes_lines?(File.binread(sides[:current]), File.binread(sides[:incoming]))
|
|
91
|
+
|
|
92
|
+
unproved.call ? CONFLICT.with(merge: TextMerge.failed(FileStanding::LEGACY_BASE)) : classified
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# .classify alone: no merge is tried.
|
|
96
|
+
# @return [Outcome]
|
|
97
|
+
def classified(file_key)
|
|
98
|
+
sides = paths(file_key)
|
|
99
|
+
Merger.classify(base_path: sides[:base], current_path: sides[:current], incoming_path: sides[:incoming])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The ones among +file_keys+ that would merge or end in a conflict. The
|
|
103
|
+
# Installer's own list is MemoryFiles.select, and a plan may ask about
|
|
104
|
+
# others: ShippedUpdate about every *.md shipped, SystemBundle about the
|
|
105
|
+
# manifest's files:. So a plan can name a file the upgrade itself
|
|
106
|
+
# doesn't merge (docs/internals/bundles.md).
|
|
107
|
+
# @param version [String, nil] the version it would bring
|
|
108
|
+
# @return [Forecast]
|
|
109
|
+
def forecast(file_keys, version: nil)
|
|
110
|
+
outcomes = file_keys.to_h { |key| [key, outcome(key)] }
|
|
111
|
+
Forecast.new(merged: outcomes.select { |_, o| o.merged? }.keys,
|
|
112
|
+
behind: outcomes.select { |_, o| o.conflict? }.map { |key, o| behind(key, o, version) })
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A :conflict outcome as a report and the mark read it: why no merge was
|
|
116
|
+
# possible, and what the bundle changed that the file doesn't get. A base
|
|
117
|
+
# that may be the user's own text (an older mark) can't say what changed.
|
|
118
|
+
# @param version [String, nil] the version the file stays behind
|
|
119
|
+
# @return [FileStanding::Behind]
|
|
120
|
+
def behind(file_key, outcome, version)
|
|
121
|
+
why = outcome.why && FileStanding.no_merge(outcome.why)
|
|
122
|
+
base = paths(file_key)[:base]
|
|
123
|
+
if InstalledBundle.legacy_mark?(provenance.record&.files&.fetch(file_key, nil))
|
|
124
|
+
return FileStanding::Behind.new(file: file_key, version: version, why: why || FileStanding::LEGACY_CONFLICT)
|
|
125
|
+
end
|
|
126
|
+
return FileStanding::Behind.new(file: file_key, version: version, why: why) unless File.exist?(base)
|
|
127
|
+
|
|
128
|
+
sides = paths(file_key)
|
|
129
|
+
headings, unnamed = TextMerge.changed_sections(File.binread(base), File.binread(sides[:incoming]), File.binread(sides[:current]))
|
|
130
|
+
# Only the places under no heading: the others are named.
|
|
131
|
+
places = unnamed ? [outcome.merge&.hunks&.count { |hunk| !hunk.union? && hunk.heading.nil? }.to_i, 1].max : 0
|
|
132
|
+
FileStanding::Behind.new(file: file_key, version: version, why: why, missed: headings, places: places)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [Hash{Symbol => String}] the file's :base, :current, :incoming
|
|
136
|
+
def paths(file_key)
|
|
137
|
+
{ base: provenance.base_path(file_key), current: File.join(target_dir, file_key),
|
|
138
|
+
incoming: File.join(source_dir, file_key) }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
12
142
|
# Classify a single file for upgrade.
|
|
13
|
-
#
|
|
143
|
+
# @return [Outcome]
|
|
14
144
|
def self.classify(base_path:, current_path:, incoming_path:)
|
|
15
145
|
base_exists = base_path && File.exist?(base_path)
|
|
16
146
|
current_exists = File.exist?(current_path)
|
|
17
147
|
incoming_exists = File.exist?(incoming_path)
|
|
18
148
|
|
|
19
|
-
return
|
|
149
|
+
return NO_INCOMING unless incoming_exists
|
|
20
150
|
|
|
21
151
|
unless current_exists
|
|
22
|
-
return
|
|
152
|
+
return INSTALL
|
|
23
153
|
end
|
|
24
154
|
|
|
25
155
|
# No base (legacy bundle without provenance or first file) -> treat as modified
|
|
26
156
|
unless base_exists
|
|
27
157
|
incoming_digest = Digest::SHA256.hexdigest(File.read(incoming_path))
|
|
28
158
|
current_digest = Digest::SHA256.hexdigest(File.read(current_path))
|
|
29
|
-
return current_digest == incoming_digest ?
|
|
159
|
+
return current_digest == incoming_digest ? NOOP : CONFLICT
|
|
30
160
|
end
|
|
31
161
|
|
|
32
162
|
base_digest = Digest::SHA256.hexdigest(File.read(base_path))
|
|
@@ -34,14 +164,85 @@ module Samagotchi
|
|
|
34
164
|
incoming_digest = Digest::SHA256.hexdigest(File.read(incoming_path))
|
|
35
165
|
|
|
36
166
|
if current_digest == incoming_digest
|
|
37
|
-
|
|
167
|
+
NOOP
|
|
38
168
|
elsif current_digest == base_digest
|
|
39
|
-
|
|
169
|
+
FAST_FORWARD
|
|
40
170
|
elsif incoming_digest == base_digest
|
|
41
|
-
|
|
171
|
+
KEEP
|
|
42
172
|
else
|
|
43
|
-
|
|
173
|
+
CONFLICT
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Both sides changed the file: the text merge's answer as an Outcome. No
|
|
178
|
+
# base means no merge: the file stays as a conflict.
|
|
179
|
+
#
|
|
180
|
+
# unproved: the file's mark may be 0.149's, whose base can be the user's own
|
|
181
|
+
# text. Merging against it reads the bundle's text as undoing that text,
|
|
182
|
+
# with no conflict to show for it, so a merge that would take any line out
|
|
183
|
+
# of the file stays a conflict, and so does such a fast-forward (Upgrade#outcome).
|
|
184
|
+
# A base some installed chi shipped can't be the user's text
|
|
185
|
+
# (ShippedHistory), and a mark from this chi has a real base: a merge there
|
|
186
|
+
# may take what the bundle removed. With no such gem left, a real base
|
|
187
|
+
# looks the same, and the file stays behind.
|
|
188
|
+
# @param sides [Hash{Symbol => String}] Upgrade#paths
|
|
189
|
+
# @param unproved [#call] whether the base may be the user's text; called
|
|
190
|
+
# only when the merge would take a line out
|
|
191
|
+
# @return [Outcome]
|
|
192
|
+
def self.merge_outcome(sides, unproved: -> { false })
|
|
193
|
+
unless sides[:base] && File.exist?(sides[:base])
|
|
194
|
+
return CONFLICT.with(merge: TextMerge.failed(TextMerge::NO_BASE))
|
|
44
195
|
end
|
|
196
|
+
|
|
197
|
+
ours = File.binread(sides[:current])
|
|
198
|
+
result = TextMerge.merge(base: File.binread(sides[:base]), ours: ours, theirs: File.binread(sides[:incoming]))
|
|
199
|
+
return CONFLICT.with(merge: result) unless result.clean?
|
|
200
|
+
return ALREADY_MERGED if result.text.b == ours.b
|
|
201
|
+
return CONFLICT.with(merge: TextMerge.failed(FileStanding::LEGACY_BASE)) if removes_lines?(ours, result.text) && unproved.call
|
|
202
|
+
|
|
203
|
+
Outcome.new(status: :merged, base: :incoming, merge: result, current_sha: Digest::SHA256.hexdigest(ours))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Whether +merged+ holds fewer copies of any line of +ours+.
|
|
207
|
+
def self.removes_lines?(ours, merged)
|
|
208
|
+
kept = merged.b.each_line.map(&:chomp).tally
|
|
209
|
+
ours.b.each_line.map(&:chomp).tally.any? { |line, count| kept.fetch(line, 0) < count }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# A :merged outcome's text goes in +path+, unless the file changed since
|
|
213
|
+
# the merge read it (a live memory_write): a last check just before the
|
|
214
|
+
# rename, so that write isn't lost. Through a symlink, its mode kept, the
|
|
215
|
+
# old file copied to +trash+ first.
|
|
216
|
+
# @return [Boolean] false: the file changed, nothing was replaced
|
|
217
|
+
def self.write_merged(path, outcome, trash)
|
|
218
|
+
return false unless digest(path) == outcome.current_sha
|
|
219
|
+
|
|
220
|
+
real = File.realpath(path)
|
|
221
|
+
trash.copy(path)
|
|
222
|
+
AtomicFile.write(real, outcome.merge.text, perm: File.stat(real).mode & 0o7777) do
|
|
223
|
+
raise ChangedDuringMerge unless digest(real) == outcome.current_sha
|
|
224
|
+
end
|
|
225
|
+
true
|
|
226
|
+
rescue ChangedDuringMerge
|
|
227
|
+
false
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
class ChangedDuringMerge < StandardError
|
|
231
|
+
end
|
|
232
|
+
private_constant :ChangedDuringMerge
|
|
233
|
+
|
|
234
|
+
# A line a merge tool leaves around a conflict. "=======" isn't one: it
|
|
235
|
+
# is also a Markdown heading's underline.
|
|
236
|
+
CONFLICT_MARKER = /^(?:<{7}|>{7})(?: |$)/
|
|
237
|
+
|
|
238
|
+
# Whether the file holds a merge's conflict markers.
|
|
239
|
+
def self.markers?(path)
|
|
240
|
+
File.exist?(path) && File.binread(path).match?(CONFLICT_MARKER)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# The file's digest, nil when there is none.
|
|
244
|
+
def self.digest(path)
|
|
245
|
+
File.exist?(path) ? Digest::SHA256.hexdigest(File.binread(path)) : nil
|
|
45
246
|
end
|
|
46
247
|
|
|
47
248
|
def self.current_modified?(base_path, current_path)
|
|
@@ -27,8 +27,10 @@ module Samagotchi
|
|
|
27
27
|
# installed: members installed now; already: members found installed
|
|
28
28
|
# (recorded, not reinstalled); skipped and failed: name => why;
|
|
29
29
|
# kept_own: member installed now => its memory files the user already
|
|
30
|
-
# had, left as they are and not owned (Installer#skip_existing)
|
|
31
|
-
|
|
30
|
+
# had, left as they are and not owned (Installer#skip_existing);
|
|
31
|
+
# trash_copies: "member: line" for what --force copied to the trash
|
|
32
|
+
# (Installer#trash_copy_line).
|
|
33
|
+
InstallResult = Struct.new(:name, :version, :installed, :already, :skipped, :failed, :kept_own, :trash_copies, keyword_init: true) do
|
|
32
34
|
def failed? = !failed.empty?
|
|
33
35
|
|
|
34
36
|
# One line per kept file (Profile.kept_own_line).
|
|
@@ -98,7 +100,7 @@ module Samagotchi
|
|
|
98
100
|
was = recorded(bundle, manifest)
|
|
99
101
|
installed = installed_names
|
|
100
102
|
result = InstallResult.new(name: manifest.name, version: manifest.version, installed: [],
|
|
101
|
-
already: (manifest.includes - was) & installed, skipped: {}, failed: {}, kept_own: {})
|
|
103
|
+
already: (manifest.includes - was) & installed, skipped: {}, failed: {}, kept_own: {}, trash_copies: [])
|
|
102
104
|
|
|
103
105
|
to_install(manifest, bundle, installed: installed).each do |member|
|
|
104
106
|
install_member(member, shipped_dir, chi_version, dry_run, force, result)
|
|
@@ -130,6 +132,7 @@ module Samagotchi
|
|
|
130
132
|
result.installed << member
|
|
131
133
|
kept = installer.results.select { |_, entry| kept_own?(entry) }.keys
|
|
132
134
|
result.kept_own[member] = kept.sort unless kept.empty?
|
|
135
|
+
result.trash_copies << "#{member}: #{installer.trash_copy_line}" if installer.trash_copy_line
|
|
133
136
|
rescue StandardError => e
|
|
134
137
|
result.failed[member] = e.message.lines.first.to_s.strip
|
|
135
138
|
end
|
|
@@ -164,6 +167,7 @@ module Samagotchi
|
|
|
164
167
|
result.removed << member
|
|
165
168
|
result.trash[member] = [uninstaller.trashed_files, uninstaller.trash_dir] if uninstaller.trash_dir
|
|
166
169
|
result.warnings.concat(uninstaller.warnings.map { |w| "#{member}: #{w}" })
|
|
170
|
+
result.warnings << "#{member}: #{uninstaller.trash_copy_line}" if uninstaller.trash_copy_line
|
|
167
171
|
rescue Uninstaller::UninstallError => e
|
|
168
172
|
result.blocked[member] = e.message
|
|
169
173
|
end
|
|
@@ -22,11 +22,23 @@ module Samagotchi
|
|
|
22
22
|
|
|
23
23
|
attr_reader :name, :bundle_dir
|
|
24
24
|
|
|
25
|
+
# What a conflict mark adds to a file's entry.
|
|
26
|
+
MARK_FIELDS = %w[conflict incoming_sha legacy_base why missed missed_places].freeze
|
|
27
|
+
# What a merge no report has shown adds to a file's entry (#note_merged).
|
|
28
|
+
MERGE_NOTE_FIELDS = %w[merged_version merged_at merged_trash].freeze
|
|
29
|
+
|
|
25
30
|
def initialize(name:)
|
|
26
31
|
@name = name
|
|
27
32
|
@bundle_dir = File.join(self.class.bundles_dir, name)
|
|
28
33
|
end
|
|
29
34
|
|
|
35
|
+
# The bundle's text a marked file missed: incoming/<file>, there until the
|
|
36
|
+
# file settles, so it outlives a git or zip source's extract. Nothing globs
|
|
37
|
+
# the bundle's folder for memories, and an uninstall's rm_rf sweeps it.
|
|
38
|
+
def incoming_path(file_key)
|
|
39
|
+
File.join(@bundle_dir, "incoming", file_key.to_s)
|
|
40
|
+
end
|
|
41
|
+
|
|
30
42
|
def hooks_dir
|
|
31
43
|
File.join(@bundle_dir, "hooks")
|
|
32
44
|
end
|
|
@@ -80,7 +92,7 @@ module Samagotchi
|
|
|
80
92
|
|
|
81
93
|
begin
|
|
82
94
|
data = JSON.parse(File.read(mjson), symbolize_names: true)
|
|
83
|
-
rescue JSON::ParserError, SystemCallError => e
|
|
95
|
+
rescue JSON::ParserError, EncodingError, SystemCallError => e # a key that isn't UTF-8 can't be a symbol
|
|
84
96
|
if holding.nil? || Dir.exist?(File.join(dir, name, holding.to_s))
|
|
85
97
|
yield name, InstalledBundle.unreadable(name, "manifest.json is unreadable: #{e.message}")
|
|
86
98
|
end
|
|
@@ -135,6 +147,11 @@ module Samagotchi
|
|
|
135
147
|
# Writes provenance for an installation: merges into existing manifest.json
|
|
136
148
|
# and saves base snapshots. Stale base snapshots (files removed from bundle)
|
|
137
149
|
# are pruned.
|
|
150
|
+
# @param files [Hash{String => String, nil}] every file the record keeps
|
|
151
|
+
# => the file whose content is its base; a key left out is dropped from
|
|
152
|
+
# the record. nil keeps an entry with no base (a kept edit that has
|
|
153
|
+
# none: the user's file must never become it), with its recorded
|
|
154
|
+
# checksum.
|
|
138
155
|
# @param hooks_files [Hash] basename => path for hook file snapshots (optional)
|
|
139
156
|
# @param guardrails_files [Hash] basename => installed rule file; the
|
|
140
157
|
# sha256 of each is recorded (the Engine checks it at load)
|
|
@@ -143,18 +160,24 @@ module Samagotchi
|
|
|
143
160
|
# snapshot
|
|
144
161
|
# @param requires_chi [String, nil] the manifest's requirement
|
|
145
162
|
# @param needs [Array<Hash>] the manifest's needs ({command:, why:, hint:})
|
|
146
|
-
# @param
|
|
147
|
-
#
|
|
163
|
+
# @param marks [Array<FileStanding::Behind>] files an upgrade left behind;
|
|
164
|
+
# their entries get conflict: true, and what the Behind says
|
|
165
|
+
# @param incoming_dir [String, nil] the bundle's source: each marked file's
|
|
166
|
+
# digest goes with the mark (incoming_sha: what Remerge retries) and its
|
|
167
|
+
# text is saved (#incoming_path), a settled file's going. A mark from an
|
|
168
|
+
# older chi that stays a mark gets legacy_base: true
|
|
169
|
+
# (InstalledBundle.legacy_mark?)
|
|
148
170
|
# @param includes [Array<String>, nil] a profile's recorded members
|
|
149
171
|
# (MemoryBundle::Profile); nil for a plain bundle
|
|
150
172
|
# @param scripts_files [Hash] file name => installed script; the
|
|
151
173
|
# sha256 of each is recorded (ContextFetch checks it before a run)
|
|
152
174
|
# @param context_providers [Array<ContextProviders::Provider>]
|
|
175
|
+
# @param block_renderers [Array<BlockRenderers::Renderer>]
|
|
153
176
|
# @param dev [Boolean] a dev install: its files are links to the
|
|
154
177
|
# source, recorded as DEV_SHA (no sha256 check) with "dev": true
|
|
155
178
|
def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
|
|
156
|
-
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil,
|
|
157
|
-
scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {},
|
|
179
|
+
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, marks: [], incoming_dir: nil, includes: nil,
|
|
180
|
+
scripts_files: {}, context_providers: [], block_renderers: [], file_descriptions: {}, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {},
|
|
158
181
|
dev: false)
|
|
159
182
|
FileUtils.mkdir_p(@bundle_dir)
|
|
160
183
|
bases_dir = File.join(@bundle_dir, "bases")
|
|
@@ -164,14 +187,26 @@ module Samagotchi
|
|
|
164
187
|
# files and hooks not in this install are stale.
|
|
165
188
|
existing = record
|
|
166
189
|
merged_entries = existing ? existing.files.dup : {}
|
|
190
|
+
marked = marks.to_h { |mark| [mark.file, mark] }
|
|
167
191
|
|
|
168
192
|
files.each do |file_key, file_path|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
193
|
+
legacy = InstalledBundle.legacy_mark?(merged_entries[file_key])
|
|
194
|
+
note = merged_entries.fetch(file_key, {}).slice(*MERGE_NOTE_FIELDS.map(&:to_sym))
|
|
195
|
+
if file_path
|
|
196
|
+
content = File.read(file_path)
|
|
197
|
+
# Save base snapshot — use file_key as-is (it already includes .md).
|
|
198
|
+
File.write(File.join(bases_dir, file_key), content)
|
|
199
|
+
merged_entries[file_key] = { checksum: Digest::SHA256.hexdigest(content) }
|
|
200
|
+
else
|
|
201
|
+
merged_entries[file_key] = merged_entries.fetch(file_key, {}).slice(:checksum)
|
|
202
|
+
end
|
|
203
|
+
merged_entries[file_key].merge!(note)
|
|
204
|
+
if (mark = marked[file_key.to_s])
|
|
205
|
+
merged_entries[file_key].merge!(mark_fields(mark))
|
|
206
|
+
merged_entries[file_key][:legacy_base] = true if legacy
|
|
207
|
+
sha = incoming_dir && incoming_sha(File.join(incoming_dir, file_key.to_s))
|
|
208
|
+
merged_entries[file_key][:incoming_sha] = sha if sha
|
|
209
|
+
end
|
|
175
210
|
merged_entries[file_key][:description] = file_descriptions[file_key.to_s] if file_descriptions[file_key.to_s]
|
|
176
211
|
end
|
|
177
212
|
|
|
@@ -255,35 +290,118 @@ module Samagotchi
|
|
|
255
290
|
end
|
|
256
291
|
end
|
|
257
292
|
manifest_data["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
|
|
293
|
+
manifest_data["block_renderers"] = block_renderers.map(&:to_h) unless block_renderers.empty?
|
|
258
294
|
manifest_data["session_settings"] = Array(session_settings).map(&:to_s) unless Array(session_settings).empty?
|
|
259
295
|
manifest_data["session_setting_types"] = session_setting_types.to_h { |k, v| [k.to_s, v.to_s] } unless session_setting_types.to_h.empty?
|
|
260
296
|
manifest_data["settings"] = settings.map(&:to_s) unless settings.nil?
|
|
261
297
|
manifest_data["setting_types"] = setting_types.to_h { |k, v| [k.to_s, v.to_s] } unless setting_types.to_h.empty?
|
|
262
298
|
|
|
263
299
|
write_manifest(manifest_data)
|
|
300
|
+
marked.each_key { |key| save_incoming(key, incoming_dir) }
|
|
301
|
+
prune_incoming(marked.keys)
|
|
264
302
|
end
|
|
265
303
|
|
|
266
|
-
# After a conflict was
|
|
267
|
-
#
|
|
268
|
-
#
|
|
304
|
+
# After a conflict was settled: each file's base becomes the incoming
|
|
305
|
+
# file and its conflict mark goes. The rest of the manifest (its
|
|
306
|
+
# description too) stays as is. A file whose incoming copy is gone keeps
|
|
307
|
+
# its mark and base: taking the file on disk would make the user's text
|
|
308
|
+
# the base, and the next upgrade would fast-forward over it.
|
|
269
309
|
# @param conflicts [Hash{String => Hash}] Installer#conflicts
|
|
310
|
+
# @return [Array<String>] the files it settled
|
|
270
311
|
def resolve_conflicts(conflicts)
|
|
271
|
-
return unless installed?
|
|
312
|
+
return [] unless installed?
|
|
272
313
|
|
|
273
314
|
raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
|
|
274
315
|
raw["files"] ||= {}
|
|
275
|
-
conflicts.
|
|
316
|
+
settled = conflicts.filter_map do |file_key, info|
|
|
276
317
|
key = file_key.to_s
|
|
277
|
-
next unless raw["files"].key?(key)
|
|
318
|
+
next unless raw["files"].key?(key) && info[:incoming] && File.exist?(info[:incoming])
|
|
278
319
|
|
|
279
|
-
|
|
280
|
-
next unless src
|
|
281
|
-
|
|
282
|
-
content = File.read(src)
|
|
320
|
+
content = File.read(info[:incoming])
|
|
283
321
|
File.write(base_path(key), content)
|
|
284
|
-
raw["files"][key] =
|
|
322
|
+
raw["files"][key] = raw["files"][key].except(*MARK_FIELDS).merge("checksum" => Digest::SHA256.hexdigest(content))
|
|
323
|
+
key
|
|
324
|
+
end
|
|
325
|
+
write_manifest(raw)
|
|
326
|
+
prune_incoming(marked_in(raw))
|
|
327
|
+
settled
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# A missing file was put back with the bundle's bytes: +path+ is its base,
|
|
331
|
+
# its entry is made or rebuilt (no mark), and the rest of the manifest stays.
|
|
332
|
+
def record_restored(file_key, path, description: nil)
|
|
333
|
+
raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
|
|
334
|
+
content = File.binread(path)
|
|
335
|
+
File.binwrite(base_path(file_key), content)
|
|
336
|
+
entry = { "checksum" => Digest::SHA256.hexdigest(content) }
|
|
337
|
+
kept = description || raw.dig("files", file_key, "description")
|
|
338
|
+
entry["description"] = kept if kept
|
|
339
|
+
(raw["files"] ||= {})[file_key] = entry
|
|
340
|
+
write_manifest(raw)
|
|
341
|
+
prune_incoming(marked_in(raw))
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# What a retry found of files still marked: the mark says it, its digest
|
|
345
|
+
# recorded if it had none (a mark made before chi kept it; legacy_base keeps
|
|
346
|
+
# that known, InstalledBundle.legacy_mark?), the bundle's text saved. The
|
|
347
|
+
# rest of the manifest stays.
|
|
348
|
+
# @param marks [Array<FileStanding::Behind>]
|
|
349
|
+
# @param incoming_dir [String] the bundle's source
|
|
350
|
+
def refresh_marks(marks, incoming_dir)
|
|
351
|
+
return unless installed?
|
|
352
|
+
|
|
353
|
+
raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
|
|
354
|
+
marks.each do |mark|
|
|
355
|
+
entry = raw.dig("files", mark.file)
|
|
356
|
+
next unless entry&.fetch("conflict", nil)
|
|
357
|
+
|
|
358
|
+
save_incoming(mark.file, incoming_dir)
|
|
359
|
+
entry.reject! { |field, _| %w[why missed missed_places].include?(field) }
|
|
360
|
+
entry.merge!(mark_fields(mark).transform_keys(&:to_s))
|
|
361
|
+
sha = incoming_sha(File.join(incoming_dir, mark.file))
|
|
362
|
+
entry.merge!("incoming_sha" => sha, "legacy_base" => true) if sha && !entry["incoming_sha"]
|
|
363
|
+
end
|
|
364
|
+
write_manifest(raw)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Every merge leaves a note on the file's entry, one no one may have seen (a
|
|
368
|
+
# worker's start logs it and nothing else, an automatic update's rows are
|
|
369
|
+
# read by no one). It stays through later upgrades, until #clear_merge_notes,
|
|
370
|
+
# which a report someone reads does once it has shown the merge.
|
|
371
|
+
# @param files [Array<String>]
|
|
372
|
+
# @param version [String] what merged
|
|
373
|
+
# @param trash [String, nil] the dir holding the old copy
|
|
374
|
+
def note_merged(files, version:, trash:, at: Time.now)
|
|
375
|
+
return unless installed?
|
|
376
|
+
|
|
377
|
+
raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
|
|
378
|
+
files.each do |key|
|
|
379
|
+
raw.dig("files", key)&.merge!({ "merged_version" => version.to_s, "merged_at" => at.iso8601, "merged_trash" => trash }.compact)
|
|
380
|
+
end
|
|
381
|
+
write_manifest(raw)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# These notes go, and a later merge's on the same file stays. A note that
|
|
385
|
+
# can't be cleared shows once more: nothing about it fails a report.
|
|
386
|
+
# @param notes [Array<FileStanding::MergeNote>] as #record gave them, read
|
|
387
|
+
# before a run
|
|
388
|
+
# @param files [Array<String>] files whose note goes whatever it says: the run
|
|
389
|
+
# just merged or replaced them. Another file's, a worker's meanwhile, stays.
|
|
390
|
+
def clear_merge_notes(notes, files: [])
|
|
391
|
+
return if notes.empty? && files.empty?
|
|
392
|
+
|
|
393
|
+
raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
|
|
394
|
+
stale = notes.select do |note|
|
|
395
|
+
entry = raw.dig("files", note.file)
|
|
396
|
+
entry && entry["merged_version"] == note.version && entry["merged_at"] == note.at
|
|
397
|
+
end
|
|
398
|
+
(stale.map(&:file) + files).each do |file|
|
|
399
|
+
entry = raw.dig("files", file)
|
|
400
|
+
MERGE_NOTE_FIELDS.each { |field| entry.delete(field) } if entry
|
|
285
401
|
end
|
|
286
402
|
write_manifest(raw)
|
|
403
|
+
rescue JSON::ParserError, SystemCallError
|
|
404
|
+
nil
|
|
287
405
|
end
|
|
288
406
|
|
|
289
407
|
# The installed bundle's record, nil when it isn't installed.
|
|
@@ -310,6 +428,48 @@ module Samagotchi
|
|
|
310
428
|
end
|
|
311
429
|
private :write_manifest
|
|
312
430
|
|
|
431
|
+
def mark_fields(mark)
|
|
432
|
+
{ conflict: true, why: mark.why, missed: mark.missed.empty? ? nil : mark.missed,
|
|
433
|
+
missed_places: mark.places.zero? ? nil : mark.places }.compact
|
|
434
|
+
end
|
|
435
|
+
private :mark_fields
|
|
436
|
+
|
|
437
|
+
def incoming_sha(path)
|
|
438
|
+
File.exist?(path) ? self.class.file_sha(path) : nil
|
|
439
|
+
rescue SystemCallError
|
|
440
|
+
nil
|
|
441
|
+
end
|
|
442
|
+
private :incoming_sha
|
|
443
|
+
|
|
444
|
+
# The saved text is a convenience, and an install's assets are already in
|
|
445
|
+
# place when this runs: failing here would leave them under the old record's
|
|
446
|
+
# shas, and a plugin that doesn't load. So nothing about it fails a write.
|
|
447
|
+
def save_incoming(file_key, incoming_dir)
|
|
448
|
+
source = incoming_dir && File.join(incoming_dir, file_key.to_s)
|
|
449
|
+
return unless source && File.exist?(source)
|
|
450
|
+
|
|
451
|
+
FileUtils.mkdir_p(File.dirname(incoming_path(file_key)))
|
|
452
|
+
FileUtils.cp(source, incoming_path(file_key))
|
|
453
|
+
rescue SystemCallError
|
|
454
|
+
nil
|
|
455
|
+
end
|
|
456
|
+
private :save_incoming
|
|
457
|
+
|
|
458
|
+
def marked_in(raw) = (raw["files"] || {}).select { |_, entry| entry["conflict"] }.keys
|
|
459
|
+
private :marked_in
|
|
460
|
+
|
|
461
|
+
# Each file that settled loses its saved text; the folder goes with the last.
|
|
462
|
+
def prune_incoming(keep)
|
|
463
|
+
dir = File.join(@bundle_dir, "incoming")
|
|
464
|
+
return unless Dir.exist?(dir)
|
|
465
|
+
|
|
466
|
+
(Dir.children(dir) - keep).each { |file| FileUtils.rm_f(File.join(dir, file)) }
|
|
467
|
+
Dir.rmdir(dir) if Dir.empty?(dir)
|
|
468
|
+
rescue SystemCallError
|
|
469
|
+
nil
|
|
470
|
+
end
|
|
471
|
+
private :prune_incoming
|
|
472
|
+
|
|
313
473
|
# Returns the path to a base snapshot for a given file key.
|
|
314
474
|
def base_path(file_key)
|
|
315
475
|
File.join(@bundle_dir, "bases", file_key)
|