samagotchi 0.149.0 → 0.150.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/README.md +4 -2
  4. data/docs/architecture.md +2 -2
  5. data/docs/cli.md +59 -24
  6. data/docs/configuration.md +5 -5
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/bridge.md +8 -0
  9. data/docs/internals/bundles.md +42 -14
  10. data/docs/internals/objectives.md +7 -2
  11. data/docs/internals/web-frontend.md +1 -1
  12. data/docs/internals/web-push.md +2 -2
  13. data/docs/internals/working-set.md +5 -2
  14. data/docs/memory.md +47 -9
  15. data/docs/plugins.md +7 -1
  16. data/docs/sessions.md +5 -4
  17. data/docs/testing.md +5 -0
  18. data/lib/samagotchi/atomic_file.rb +3 -0
  19. data/lib/samagotchi/bootstrap_command.rb +5 -2
  20. data/lib/samagotchi/bridge/card_store.rb +52 -7
  21. data/lib/samagotchi/bridge.rb +1 -1
  22. data/lib/samagotchi/bundle_command.rb +91 -68
  23. data/lib/samagotchi/bundler_env.rb +24 -0
  24. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  26. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -1
  27. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -1
  28. data/lib/samagotchi/bundles/known-names/manifest.yml +3 -2
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +2 -2
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  31. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  32. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  33. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  34. data/lib/samagotchi/chi_home.rb +4 -2
  35. data/lib/samagotchi/chi_shim.rb +2 -1
  36. data/lib/samagotchi/child_env.rb +10 -3
  37. data/lib/samagotchi/engine.rb +1 -1
  38. data/lib/samagotchi/git_env.rb +43 -0
  39. data/lib/samagotchi/guardrails/context.rb +2 -0
  40. data/lib/samagotchi/guardrails/targets.rb +4 -1
  41. data/lib/samagotchi/memory_bundle/asset_installer.rb +16 -2
  42. data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
  43. data/lib/samagotchi/memory_bundle/installed_bundle.rb +62 -1
  44. data/lib/samagotchi/memory_bundle/installer.rb +161 -52
  45. data/lib/samagotchi/memory_bundle/listing.rb +4 -3
  46. data/lib/samagotchi/memory_bundle/merger.rb +209 -8
  47. data/lib/samagotchi/memory_bundle/placeholder.rb +2 -1
  48. data/lib/samagotchi/memory_bundle/profile.rb +7 -3
  49. data/lib/samagotchi/memory_bundle/provenance.rb +178 -20
  50. data/lib/samagotchi/memory_bundle/remerge.rb +167 -0
  51. data/lib/samagotchi/memory_bundle/shipped_history.rb +79 -0
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +89 -24
  53. data/lib/samagotchi/memory_bundle/source.rb +3 -3
  54. data/lib/samagotchi/memory_bundle/status.rb +21 -3
  55. data/lib/samagotchi/memory_bundle/system_bundle.rb +98 -33
  56. data/lib/samagotchi/memory_bundle/text_merge.rb +399 -0
  57. data/lib/samagotchi/memory_bundle/trash.rb +58 -2
  58. data/lib/samagotchi/memory_bundle/uninstaller.rb +26 -6
  59. data/lib/samagotchi/objectives_check.rb +37 -7
  60. data/lib/samagotchi/project_env.rb +3 -2
  61. data/lib/samagotchi/self_report.rb +2 -0
  62. data/lib/samagotchi/testing/boundary.rb +1 -0
  63. data/lib/samagotchi/testing/minitest.rb +3 -0
  64. data/lib/samagotchi/testing/rspec.rb +3 -0
  65. data/lib/samagotchi/testing/sandbox.rb +3 -1
  66. data/lib/samagotchi/tools/delegate.rb +2 -1
  67. data/lib/samagotchi/tools/task_runtime.rb +13 -10
  68. data/lib/samagotchi/turn_flow.rb +3 -1
  69. data/lib/samagotchi/update_command.rb +76 -28
  70. data/lib/samagotchi/update_hint.rb +4 -1
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/auto_update.rb +8 -8
  73. data/lib/samagotchi/web/auto_update_record.rb +6 -4
  74. data/lib/samagotchi/web/participant_view.rb +1 -1
  75. data/lib/samagotchi/web/public/app.js +5 -3
  76. data/lib/samagotchi/web/public/block_open.js +13 -0
  77. data/lib/samagotchi/web/public/bubbles.js +4 -2
  78. data/lib/samagotchi/web/public/cards_place.js +10 -5
  79. data/lib/samagotchi/web/public/details_motion.js +2 -1
  80. data/lib/samagotchi/web/public/index.html +18 -14
  81. data/lib/samagotchi/web/public/stage_model.js +5 -6
  82. data/lib/samagotchi/web/public/stage_view.js +8 -7
  83. data/lib/samagotchi/web/public/turn_block.js +34 -19
  84. data/lib/samagotchi/web/public/turn_model.js +1 -1
  85. data/lib/samagotchi/web/public/update_sheet.js +8 -3
  86. data/lib/samagotchi/web/public/working_set_view.js +10 -7
  87. data/lib/samagotchi/web/push/notifier.rb +7 -9
  88. data/lib/samagotchi/web/session_hub.rb +2 -1
  89. data/lib/samagotchi/web/update_ops.rb +2 -1
  90. metadata +8 -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
- # Returns :install, :keep, :fast_forward, :conflict, :noop
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 :noop unless incoming_exists
149
+ return NO_INCOMING unless incoming_exists
20
150
 
21
151
  unless current_exists
22
- return :install
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 ? :noop : :conflict
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
- :noop
167
+ NOOP
38
168
  elsif current_digest == base_digest
39
- :fast_forward
169
+ FAST_FORWARD
40
170
  elsif incoming_digest == base_digest
41
- :keep
171
+ KEEP
42
172
  else
43
- :conflict
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)
@@ -25,7 +25,8 @@ module Samagotchi
25
25
  private
26
26
 
27
27
  def extract_placeholders(content)
28
- content.scan(PLACEHOLDER_RE).flatten
28
+ # A memory may hold bytes that aren't UTF-8: a hint must not fail an install.
29
+ content.scrub.scan(PLACEHOLDER_RE).flatten
29
30
  end
30
31
  end
31
32
  end
@@ -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
- InstallResult = Struct.new(:name, :version, :installed, :already, :skipped, :failed, :kept_own, keyword_init: true) do
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
@@ -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,8 +160,13 @@ 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 conflicts [Array<String>] files an upgrade kept with local
147
- # edits that conflict with it; their entries get conflict: true
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
@@ -153,7 +175,7 @@ module Samagotchi
153
175
  # @param dev [Boolean] a dev install: its files are links to the
154
176
  # source, recorded as DEV_SHA (no sha256 check) with "dev": true
155
177
  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, conflicts: [], includes: nil,
178
+ guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, marks: [], incoming_dir: nil, includes: nil,
157
179
  scripts_files: {}, context_providers: [], file_descriptions: {}, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {},
158
180
  dev: false)
159
181
  FileUtils.mkdir_p(@bundle_dir)
@@ -164,14 +186,26 @@ module Samagotchi
164
186
  # files and hooks not in this install are stale.
165
187
  existing = record
166
188
  merged_entries = existing ? existing.files.dup : {}
189
+ marked = marks.to_h { |mark| [mark.file, mark] }
167
190
 
168
191
  files.each do |file_key, file_path|
169
- content = File.read(file_path)
170
- checksum = Digest::SHA256.hexdigest(content)
171
- # Save base snapshot — use file_key as-is (it already includes .md).
172
- File.write(File.join(bases_dir, file_key), content)
173
- merged_entries[file_key] = { checksum: checksum }
174
- merged_entries[file_key][:conflict] = true if conflicts.include?(file_key.to_s)
192
+ legacy = InstalledBundle.legacy_mark?(merged_entries[file_key])
193
+ note = merged_entries.fetch(file_key, {}).slice(*MERGE_NOTE_FIELDS.map(&:to_sym))
194
+ if file_path
195
+ content = File.read(file_path)
196
+ # Save base snapshot — use file_key as-is (it already includes .md).
197
+ File.write(File.join(bases_dir, file_key), content)
198
+ merged_entries[file_key] = { checksum: Digest::SHA256.hexdigest(content) }
199
+ else
200
+ merged_entries[file_key] = merged_entries.fetch(file_key, {}).slice(:checksum)
201
+ end
202
+ merged_entries[file_key].merge!(note)
203
+ if (mark = marked[file_key.to_s])
204
+ merged_entries[file_key].merge!(mark_fields(mark))
205
+ merged_entries[file_key][:legacy_base] = true if legacy
206
+ sha = incoming_dir && incoming_sha(File.join(incoming_dir, file_key.to_s))
207
+ merged_entries[file_key][:incoming_sha] = sha if sha
208
+ end
175
209
  merged_entries[file_key][:description] = file_descriptions[file_key.to_s] if file_descriptions[file_key.to_s]
176
210
  end
177
211
 
@@ -261,29 +295,111 @@ module Samagotchi
261
295
  manifest_data["setting_types"] = setting_types.to_h { |k, v| [k.to_s, v.to_s] } unless setting_types.to_h.empty?
262
296
 
263
297
  write_manifest(manifest_data)
298
+ marked.each_key { |key| save_incoming(key, incoming_dir) }
299
+ prune_incoming(marked.keys)
264
300
  end
265
301
 
266
- # After a conflict was resolved by hand: each file's base becomes the
267
- # bundle's version (the incoming file, else what's on disk now) and
268
- # its conflict mark goes. The rest of the manifest stays as is.
302
+ # After a conflict was settled: each file's base becomes the incoming
303
+ # file and its conflict mark goes. The rest of the manifest (its
304
+ # description too) stays as is. A file whose incoming copy is gone keeps
305
+ # its mark and base: taking the file on disk would make the user's text
306
+ # the base, and the next upgrade would fast-forward over it.
269
307
  # @param conflicts [Hash{String => Hash}] Installer#conflicts
308
+ # @return [Array<String>] the files it settled
270
309
  def resolve_conflicts(conflicts)
271
- return unless installed?
310
+ return [] unless installed?
272
311
 
273
312
  raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
274
313
  raw["files"] ||= {}
275
- conflicts.each do |file_key, info|
314
+ settled = conflicts.filter_map do |file_key, info|
276
315
  key = file_key.to_s
277
- next unless raw["files"].key?(key)
316
+ next unless raw["files"].key?(key) && info[:incoming] && File.exist?(info[:incoming])
278
317
 
279
- src = [info[:incoming], info[:current]].find { |p| p && File.exist?(p) }
280
- next unless src
281
-
282
- content = File.read(src)
318
+ content = File.read(info[:incoming])
283
319
  File.write(base_path(key), content)
284
- raw["files"][key] = { "checksum" => Digest::SHA256.hexdigest(content) }
320
+ raw["files"][key] = raw["files"][key].except(*MARK_FIELDS).merge("checksum" => Digest::SHA256.hexdigest(content))
321
+ key
322
+ end
323
+ write_manifest(raw)
324
+ prune_incoming(marked_in(raw))
325
+ settled
326
+ end
327
+
328
+ # A missing file was put back with the bundle's bytes: +path+ is its base,
329
+ # its entry is made or rebuilt (no mark), and the rest of the manifest stays.
330
+ def record_restored(file_key, path, description: nil)
331
+ raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
332
+ content = File.binread(path)
333
+ File.binwrite(base_path(file_key), content)
334
+ entry = { "checksum" => Digest::SHA256.hexdigest(content) }
335
+ kept = description || raw.dig("files", file_key, "description")
336
+ entry["description"] = kept if kept
337
+ (raw["files"] ||= {})[file_key] = entry
338
+ write_manifest(raw)
339
+ prune_incoming(marked_in(raw))
340
+ end
341
+
342
+ # What a retry found of files still marked: the mark says it, its digest
343
+ # recorded if it had none (a mark made before chi kept it; legacy_base keeps
344
+ # that known, InstalledBundle.legacy_mark?), the bundle's text saved. The
345
+ # rest of the manifest stays.
346
+ # @param marks [Array<FileStanding::Behind>]
347
+ # @param incoming_dir [String] the bundle's source
348
+ def refresh_marks(marks, incoming_dir)
349
+ return unless installed?
350
+
351
+ raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
352
+ marks.each do |mark|
353
+ entry = raw.dig("files", mark.file)
354
+ next unless entry&.fetch("conflict", nil)
355
+
356
+ save_incoming(mark.file, incoming_dir)
357
+ entry.reject! { |field, _| %w[why missed missed_places].include?(field) }
358
+ entry.merge!(mark_fields(mark).transform_keys(&:to_s))
359
+ sha = incoming_sha(File.join(incoming_dir, mark.file))
360
+ entry.merge!("incoming_sha" => sha, "legacy_base" => true) if sha && !entry["incoming_sha"]
361
+ end
362
+ write_manifest(raw)
363
+ end
364
+
365
+ # Every merge leaves a note on the file's entry, one no one may have seen (a
366
+ # worker's start logs it and nothing else, an automatic update's rows are
367
+ # read by no one). It stays through later upgrades, until #clear_merge_notes,
368
+ # which a report someone reads does once it has shown the merge.
369
+ # @param files [Array<String>]
370
+ # @param version [String] what merged
371
+ # @param trash [String, nil] the dir holding the old copy
372
+ def note_merged(files, version:, trash:, at: Time.now)
373
+ return unless installed?
374
+
375
+ raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
376
+ files.each do |key|
377
+ raw.dig("files", key)&.merge!({ "merged_version" => version.to_s, "merged_at" => at.iso8601, "merged_trash" => trash }.compact)
378
+ end
379
+ write_manifest(raw)
380
+ end
381
+
382
+ # These notes go, and a later merge's on the same file stays. A note that
383
+ # can't be cleared shows once more: nothing about it fails a report.
384
+ # @param notes [Array<FileStanding::MergeNote>] as #record gave them, read
385
+ # before a run
386
+ # @param files [Array<String>] files whose note goes whatever it says: the run
387
+ # just merged or replaced them. Another file's, a worker's meanwhile, stays.
388
+ def clear_merge_notes(notes, files: [])
389
+ return if notes.empty? && files.empty?
390
+
391
+ raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
392
+ stale = notes.select do |note|
393
+ entry = raw.dig("files", note.file)
394
+ entry && entry["merged_version"] == note.version && entry["merged_at"] == note.at
395
+ end
396
+ (stale.map(&:file) + files).each do |file|
397
+ entry = raw.dig("files", file)
398
+ MERGE_NOTE_FIELDS.each { |field| entry.delete(field) } if entry
285
399
  end
286
400
  write_manifest(raw)
401
+ rescue JSON::ParserError, SystemCallError
402
+ nil
287
403
  end
288
404
 
289
405
  # The installed bundle's record, nil when it isn't installed.
@@ -310,6 +426,48 @@ module Samagotchi
310
426
  end
311
427
  private :write_manifest
312
428
 
429
+ def mark_fields(mark)
430
+ { conflict: true, why: mark.why, missed: mark.missed.empty? ? nil : mark.missed,
431
+ missed_places: mark.places.zero? ? nil : mark.places }.compact
432
+ end
433
+ private :mark_fields
434
+
435
+ def incoming_sha(path)
436
+ File.exist?(path) ? self.class.file_sha(path) : nil
437
+ rescue SystemCallError
438
+ nil
439
+ end
440
+ private :incoming_sha
441
+
442
+ # The saved text is a convenience, and an install's assets are already in
443
+ # place when this runs: failing here would leave them under the old record's
444
+ # shas, and a plugin that doesn't load. So nothing about it fails a write.
445
+ def save_incoming(file_key, incoming_dir)
446
+ source = incoming_dir && File.join(incoming_dir, file_key.to_s)
447
+ return unless source && File.exist?(source)
448
+
449
+ FileUtils.mkdir_p(File.dirname(incoming_path(file_key)))
450
+ FileUtils.cp(source, incoming_path(file_key))
451
+ rescue SystemCallError
452
+ nil
453
+ end
454
+ private :save_incoming
455
+
456
+ def marked_in(raw) = (raw["files"] || {}).select { |_, entry| entry["conflict"] }.keys
457
+ private :marked_in
458
+
459
+ # Each file that settled loses its saved text; the folder goes with the last.
460
+ def prune_incoming(keep)
461
+ dir = File.join(@bundle_dir, "incoming")
462
+ return unless Dir.exist?(dir)
463
+
464
+ (Dir.children(dir) - keep).each { |file| FileUtils.rm_f(File.join(dir, file)) }
465
+ Dir.rmdir(dir) if Dir.empty?(dir)
466
+ rescue SystemCallError
467
+ nil
468
+ end
469
+ private :prune_incoming
470
+
313
471
  # Returns the path to a base snapshot for a given file key.
314
472
  def base_path(file_key)
315
473
  File.join(@bundle_dir, "bases", file_key)