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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -1
  3. data/README.md +4 -2
  4. data/docs/architecture.md +4 -4
  5. data/docs/bundle-authoring.md +1 -1
  6. data/docs/cli.md +59 -24
  7. data/docs/configuration.md +6 -6
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/block-renderers.md +96 -0
  10. data/docs/internals/bridge.md +53 -9
  11. data/docs/internals/bundles.md +43 -14
  12. data/docs/internals/objectives.md +7 -2
  13. data/docs/internals/web-frontend.md +2 -2
  14. data/docs/internals/web-push.md +2 -2
  15. data/docs/internals/working-set.md +5 -2
  16. data/docs/memory.md +48 -10
  17. data/docs/plugins.md +49 -3
  18. data/docs/sessions.md +5 -4
  19. data/docs/testing.md +5 -0
  20. data/lib/samagotchi/atomic_file.rb +3 -0
  21. data/lib/samagotchi/block_renderers.rb +115 -0
  22. data/lib/samagotchi/bootstrap_command.rb +5 -2
  23. data/lib/samagotchi/bridge/card_places.rb +93 -0
  24. data/lib/samagotchi/bridge/card_store.rb +224 -82
  25. data/lib/samagotchi/bridge.rb +12 -4
  26. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  27. data/lib/samagotchi/bundle_command.rb +91 -68
  28. data/lib/samagotchi/bundler_env.rb +24 -0
  29. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  31. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -1
  32. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -1
  33. data/lib/samagotchi/bundles/known-names/manifest.yml +3 -2
  34. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +2 -2
  35. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  38. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  39. data/lib/samagotchi/chi_home.rb +4 -2
  40. data/lib/samagotchi/chi_shim.rb +2 -1
  41. data/lib/samagotchi/child_env.rb +10 -3
  42. data/lib/samagotchi/engine.rb +19 -1
  43. data/lib/samagotchi/git_env.rb +43 -0
  44. data/lib/samagotchi/guardrails/context.rb +2 -0
  45. data/lib/samagotchi/guardrails/targets.rb +4 -1
  46. data/lib/samagotchi/memory_bundle/asset_installer.rb +23 -3
  47. data/lib/samagotchi/memory_bundle/builder.rb +5 -3
  48. data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
  49. data/lib/samagotchi/memory_bundle/installed_bundle.rb +70 -6
  50. data/lib/samagotchi/memory_bundle/installed_copy.rb +1 -0
  51. data/lib/samagotchi/memory_bundle/installer.rb +162 -52
  52. data/lib/samagotchi/memory_bundle/listing.rb +4 -3
  53. data/lib/samagotchi/memory_bundle/manifest.rb +23 -2
  54. data/lib/samagotchi/memory_bundle/merger.rb +209 -8
  55. data/lib/samagotchi/memory_bundle/placeholder.rb +2 -1
  56. data/lib/samagotchi/memory_bundle/profile.rb +7 -3
  57. data/lib/samagotchi/memory_bundle/provenance.rb +182 -22
  58. data/lib/samagotchi/memory_bundle/remerge.rb +167 -0
  59. data/lib/samagotchi/memory_bundle/shipped_history.rb +79 -0
  60. data/lib/samagotchi/memory_bundle/shipped_update.rb +89 -24
  61. data/lib/samagotchi/memory_bundle/source.rb +3 -3
  62. data/lib/samagotchi/memory_bundle/status.rb +21 -3
  63. data/lib/samagotchi/memory_bundle/system_bundle.rb +98 -33
  64. data/lib/samagotchi/memory_bundle/text_merge.rb +399 -0
  65. data/lib/samagotchi/memory_bundle/trash.rb +58 -2
  66. data/lib/samagotchi/memory_bundle/uninstaller.rb +26 -6
  67. data/lib/samagotchi/objectives_check.rb +37 -7
  68. data/lib/samagotchi/project_env.rb +3 -2
  69. data/lib/samagotchi/self_report.rb +2 -0
  70. data/lib/samagotchi/session_chain.rb +2 -7
  71. data/lib/samagotchi/session_commands.rb +15 -9
  72. data/lib/samagotchi/session_manager.rb +10 -7
  73. data/lib/samagotchi/steer.rb +17 -0
  74. data/lib/samagotchi/terminal_ui.rb +4 -4
  75. data/lib/samagotchi/testing/boundary.rb +1 -0
  76. data/lib/samagotchi/testing/minitest.rb +3 -0
  77. data/lib/samagotchi/testing/rspec.rb +3 -0
  78. data/lib/samagotchi/testing/sandbox.rb +3 -1
  79. data/lib/samagotchi/tools/delegate.rb +2 -1
  80. data/lib/samagotchi/tools/task_runtime.rb +13 -10
  81. data/lib/samagotchi/turn_checkpoint.rb +11 -5
  82. data/lib/samagotchi/turn_flow.rb +2 -1
  83. data/lib/samagotchi/update_command.rb +76 -28
  84. data/lib/samagotchi/update_hint.rb +4 -1
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +22 -12
  87. data/lib/samagotchi/web/auto_update.rb +8 -8
  88. data/lib/samagotchi/web/auto_update_record.rb +6 -4
  89. data/lib/samagotchi/web/block_renderer_routes.rb +32 -0
  90. data/lib/samagotchi/web/markdown_renderer.rb +39 -6
  91. data/lib/samagotchi/web/message_display.rb +3 -3
  92. data/lib/samagotchi/web/public/app.js +7 -3
  93. data/lib/samagotchi/web/public/block_frame.js +202 -0
  94. data/lib/samagotchi/web/public/block_host.js +502 -0
  95. data/lib/samagotchi/web/public/block_open.js +13 -0
  96. data/lib/samagotchi/web/public/block_renderers.css +20 -0
  97. data/lib/samagotchi/web/public/block_source.js +37 -0
  98. data/lib/samagotchi/web/public/bubbles.js +11 -5
  99. data/lib/samagotchi/web/public/card.js +17 -10
  100. data/lib/samagotchi/web/public/cards_place.js +10 -5
  101. data/lib/samagotchi/web/public/claim_link.js +4 -2
  102. data/lib/samagotchi/web/public/details_motion.js +2 -1
  103. data/lib/samagotchi/web/public/history_reveal.js +4 -1
  104. data/lib/samagotchi/web/public/index.html +20 -14
  105. data/lib/samagotchi/web/public/markdown_place.js +2 -3
  106. data/lib/samagotchi/web/public/stage_model.js +5 -6
  107. data/lib/samagotchi/web/public/stage_view.js +8 -7
  108. data/lib/samagotchi/web/public/text_roots.js +6 -0
  109. data/lib/samagotchi/web/public/turn_block.js +34 -19
  110. data/lib/samagotchi/web/public/turn_html.js +4 -1
  111. data/lib/samagotchi/web/public/turn_model.js +1 -1
  112. data/lib/samagotchi/web/public/update_sheet.js +8 -3
  113. data/lib/samagotchi/web/public/working_set_view.js +10 -7
  114. data/lib/samagotchi/web/push/notifier.rb +7 -9
  115. data/lib/samagotchi/web/session_hub.rb +2 -1
  116. data/lib/samagotchi/web/session_show.rb +1 -1
  117. data/lib/samagotchi/web/update_ops.rb +2 -1
  118. data/lib/samagotchi/worker.rb +4 -2
  119. metadata +17 -1
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The variables that tell a git which repo to act on. A process started by
5
+ # git (`rebase --exec`, `bisect run`, a hook) inherits them, and then its
6
+ # `git -C dir ...`, or a clone into a temp dir, reads or writes that repo
7
+ # instead of dir. chi's own git calls on a path chi chose spawn with UNSET;
8
+ # git then finds the repo from its cwd or -C. A model's and a user's commands
9
+ # (execute, tasks, !cmd) are theirs: they keep what chi was given.
10
+ module GitEnv
11
+ # `git rev-parse --local-env-vars` (git_env_spec checks it) less
12
+ # GIT_CONFIG_COUNT, which only adds settings and which git never sets for
13
+ # a child: like ~/.gitconfig it is the user's (a CI's clone credentials),
14
+ # and the suite's own (spec_helper). spec/e2e/support/env.js asks git for
15
+ # the same list.
16
+ VARS = %w[
17
+ GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_DIR GIT_GRAFT_FILE
18
+ GIT_IMPLICIT_WORK_TREE GIT_INDEX_FILE GIT_NO_REPLACE_OBJECTS GIT_OBJECT_DIRECTORY GIT_PREFIX
19
+ GIT_REPLACE_REF_BASE GIT_SHALLOW_FILE GIT_WORK_TREE
20
+ ].freeze
21
+
22
+ # What a spawn takes as its env: Process.spawn unsets a key mapped to nil.
23
+ # Not GIT_CONFIG_PARAMETERS, GIT_CONFIG_COUNT's older form (`git -c ...` around
24
+ # chi): settings for this git, not a repo, like a url.insteadOf for a clone.
25
+ # A test run clears it (VARS); what wants no config at all says so (TextMerge).
26
+ UNSET = (VARS - ["GIT_CONFIG_PARAMETERS"]).to_h { |name| [name, nil] }.freeze
27
+
28
+ module_function
29
+
30
+ # For a test run, as spec_helper and the kit's adapters load: its specs'
31
+ # `git -C tmp init` must not reach the real repo. Clears them for this
32
+ # process and all it starts, also from the copy Bundler keeps of the
33
+ # environment it started in: Bundler.with_unbundled_env and
34
+ # InstalledGem.unbundled_env_changes would put them back in a child.
35
+ def scrub!
36
+ clear(ENV)
37
+ clear(::Bundler::ORIGINAL_ENV) if defined?(::Bundler::ORIGINAL_ENV)
38
+ end
39
+
40
+ # @param env [#delete] ENV or a Hash
41
+ def clear(env) = VARS.each { |name| env.delete(name) }
42
+ end
43
+ end
@@ -93,6 +93,8 @@ module Samagotchi
93
93
  return {} unless File.directory?(dir)
94
94
 
95
95
  paths = ["--show-toplevel", "--git-path", "hooks", "--git-common-dir"]
96
+ # In chi's own environment, on purpose: a tool's git inherits it, and a
97
+ # card or a rule must judge the repo that git acts on (GIT_DIR around chi too).
96
98
  out, status = Open3.capture2e("git", "-C", dir, "rev-parse", *paths, "--abbrev-ref", "HEAD")
97
99
  # A repo with no commits yet has no HEAD: only the paths answer.
98
100
  out, status = Open3.capture2e("git", "-C", dir, "rev-parse", *paths) unless status.success?
@@ -401,7 +401,10 @@ module Samagotchi
401
401
  # :unknown for one the text doesn't tell); [] for other tools.
402
402
  def git_dirs
403
403
  @git_dirs ||= if shell?
404
- # chi's own GIT_DIR is not the command's concern
404
+ # The text's and the call's env: only. chi's own GIT_DIR and
405
+ # GIT_WORK_TREE aren't counted here, though execute inherits
406
+ # them and the command does act on that repo; GitInfo (the
407
+ # repo root and common dir this is compared with) follows them.
405
408
  shell_envs.each_with_index.flat_map do |(home, vars), i|
406
409
  ShellGitDirs.for(@command, cwd: @cwd, home: home, env: i.zero? ? {} : @env, vars: vars)
407
410
  end.uniq
@@ -19,6 +19,8 @@ module Samagotchi
19
19
  # warnings, and #run returns what the record (Provenance#write) keeps.
20
20
  # A dev install (+dev+, `chi bundle install --dev`) links each file to
21
21
  # the source instead of copying it, and warns of no checksum mismatch.
22
+ # An edited file it replaces or removes is copied to the trash first,
23
+ # under its subfolder (Trash#copy_edit); a link never is.
22
24
  class AssetInstaller
23
25
  # What was installed: the paths Provenance#write records (each
24
26
  # empty / nil on a dry run).
@@ -33,7 +35,8 @@ module Samagotchi
33
35
  # @param existing [InstalledBundle, nil] the record before this install
34
36
  # @param results, warnings, conflicts the Installer's (results and
35
37
  # warnings are added to)
36
- def initialize(name:, source_dir:, manifest:, provenance:, existing:, results:, warnings:, conflicts:,
38
+ # @param trash [Trash] the Installer's
39
+ def initialize(name:, source_dir:, manifest:, provenance:, existing:, results:, warnings:, conflicts:, trash:,
37
40
  force: false, strict: true, upgrade: false, dry_run: false, dev: false)
38
41
  @name = name
39
42
  @bundle_dir = source_dir
@@ -43,6 +46,7 @@ module Samagotchi
43
46
  @results = results
44
47
  @warnings = warnings
45
48
  @conflicts = conflicts
49
+ @trash = trash
46
50
  @force = force
47
51
  @strict = strict
48
52
  @upgrade = upgrade
@@ -112,6 +116,7 @@ module Samagotchi
112
116
 
113
117
  FileUtils.mkdir_p(@provenance.hooks_dir)
114
118
  unchanged = File.exist?(dest) && FileUtils.identical?(src, dest)
119
+ @trash.copy_edit(dest, as: "hooks/#{basename}", recorded: prev_sha, incoming: src)
115
120
  place(src, dest)
116
121
  @results[basename] = if unchanged
117
122
  { status: "skipped", reason: "already up to date" }
@@ -135,6 +140,7 @@ module Samagotchi
135
140
  else
136
141
  FileUtils.mkdir_p(@provenance.hooks_dir)
137
142
  dest = File.join(@provenance.hooks_dir, basename)
143
+ @trash.copy_edit(dest, as: "hooks/#{basename}", recorded: @existing&.hooks&.dig(basename)&.sha256, incoming: src)
138
144
  place(src, dest)
139
145
  @results[basename] = { status: "installed" }
140
146
  @hook_files[basename] = dest
@@ -152,6 +158,7 @@ module Samagotchi
152
158
  if File.exist?(old_dest) && !@force && !File.symlink?(old_dest)
153
159
  @warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
154
160
  end
161
+ @trash.copy_edit(old_dest, as: "hooks/#{old_key_str}", recorded: @existing.hooks[old_key_str]&.sha256)
155
162
  FileUtils.rm_f(old_dest)
156
163
  end
157
164
  end
@@ -190,6 +197,7 @@ module Samagotchi
190
197
  dest = File.join(target, File.basename(src))
191
198
  File.file?(dest) && FileUtils.identical?(src, dest)
192
199
  end
200
+ @trash.copy_edits(target, "guardrails", @existing&.guardrails, incoming_rules.to_h { |src| [File.basename(src), src] })
193
201
  FileUtils.rm_rf(target)
194
202
  return {} if incoming_rules.empty?
195
203
 
@@ -210,7 +218,10 @@ module Samagotchi
210
218
  def install_plugin
211
219
  plugin = @manifest&.plugin
212
220
  unless plugin
213
- FileUtils.rm_rf(@provenance.plugin_dir) unless @dry_run
221
+ unless @dry_run
222
+ @trash.copy_edits(@provenance.plugin_dir, "plugin", @existing&.plugin_shas)
223
+ FileUtils.rm_rf(@provenance.plugin_dir)
224
+ end
214
225
  return nil
215
226
  end
216
227
 
@@ -228,6 +239,7 @@ module Samagotchi
228
239
 
229
240
  dest = File.join(@provenance.plugin_dir, plugin[:file])
230
241
  unchanged = File.file?(dest) && FileUtils.identical?(src, dest)
242
+ @trash.copy_edits(@provenance.plugin_dir, "plugin", @existing&.plugin_shas, { plugin[:file] => src })
231
243
  FileUtils.rm_rf(@provenance.plugin_dir)
232
244
  FileUtils.mkdir_p(@provenance.plugin_dir)
233
245
  place(src, dest)
@@ -257,7 +269,7 @@ module Samagotchi
257
269
  raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have" unless File.file?(src)
258
270
  raise Installer::InstallError, "scripts/#{file} is a symlink (not installed)" if linked?(src)
259
271
 
260
- @warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src)
272
+ @warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src) && !page_only?(file)
261
273
  end
262
274
  return {} if @dry_run
263
275
 
@@ -265,6 +277,8 @@ module Samagotchi
265
277
  dest = File.join(@provenance.scripts_dir, file)
266
278
  File.file?(dest) && FileUtils.identical?(File.join(@bundle_dir, "scripts", file), dest)
267
279
  end
280
+ @trash.copy_edits(@provenance.scripts_dir, "scripts", @existing&.scripts,
281
+ scripts.keys.to_h { |file| [file, File.join(@bundle_dir, "scripts", file)] })
268
282
  FileUtils.rm_rf(@provenance.scripts_dir)
269
283
  return {} if scripts.empty?
270
284
 
@@ -286,6 +300,12 @@ module Samagotchi
286
300
  end
287
301
  end
288
302
 
303
+ # A file only a block renderer's load names: the page loads it, nothing runs it.
304
+ def page_only?(file)
305
+ @manifest.block_renderers.any? { |renderer| renderer.load.include?(file) } &&
306
+ @manifest.context_providers.none? { |provider| provider.cmd.include?(file) }
307
+ end
308
+
289
309
  # Copies +src+ to +dest+, or (a dev install) links +dest+ to it. An
290
310
  # existing link goes first: a copy over it would write into the source.
291
311
  def place(src, dest)
@@ -30,8 +30,8 @@ module Samagotchi
30
30
  # hooks basename => BundleHook; hook_files basename => path
31
31
  # rules guardrails/*.yml paths
32
32
  # plugin the installed plugin file, nil without one
33
- # scripts file name => path; context_providers the parsed list
34
- InstalledParts = Data.define(:record, :hooks, :hook_files, :rules, :plugin, :scripts, :context_providers)
33
+ # scripts file name => path; context_providers, block_renderers the parsed lists
34
+ InstalledParts = Data.define(:record, :hooks, :hook_files, :rules, :plugin, :scripts, :context_providers, :block_renderers)
35
35
 
36
36
  attr_reader :built_files, :warnings, :placeholder_warnings, :out_path, :staging_dir
37
37
 
@@ -179,7 +179,8 @@ module Samagotchi
179
179
  rules: Dir.exist?(rules_dir) ? Dir.glob(File.join(rules_dir, "*.yml")).sort : [],
180
180
  plugin: plugin && File.file?(plugin) ? plugin : nil,
181
181
  scripts: installed_scripts(prov, record),
182
- context_providers: record ? ContextProviders.parse_list(record.context_providers) : []
182
+ context_providers: record ? ContextProviders.parse_list(record.context_providers) : [],
183
+ block_renderers: record ? BlockRenderers.parse_list(record.block_renderers) : []
183
184
  )
184
185
  end
185
186
 
@@ -223,6 +224,7 @@ module Samagotchi
223
224
  needs: record&.needs,
224
225
  scripts: parts.scripts.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
225
226
  context_providers: parts.context_providers,
227
+ block_renderers: parts.block_renderers,
226
228
  file_descriptions: file_descriptions(record, files_map),
227
229
  session_settings: record&.session_settings || [],
228
230
  session_setting_types: record&.session_setting_types || {},
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "time"
5
+
6
+ module Samagotchi
7
+ module MemoryBundle
8
+ # What chi says of an edited bundle file, everywhere it reports one: the one
9
+ # owner of these words (chi update's row and --json, chi bundle list and
10
+ # status, chi self, chi bootstrap, SystemBundle.ensure!'s echo, the Installer's
11
+ # summary, the phone's push).
12
+ #
13
+ # A file is merged (it took the update) or behind: marked conflict on its
14
+ # record, with bytes other than the bundle's text it speaks of. A marked file
15
+ # brought level by hand isn't behind, and a missing one is a missing file.
16
+ module FileStanding
17
+ # Why a merge of a mark from an older chi didn't go ahead (Merger.merge_outcome).
18
+ LEGACY_BASE = "an older chi may have taken your text as the base, and the merge would remove lines from your file"
19
+ # A real conflict on such a mark: its base can't say what the bundle changed.
20
+ LEGACY_CONFLICT = "it conflicts with the bundle's text, and an older chi's base can't say what it missed"
21
+ # A marked file Remerge leaves for the next upgrade or restore.
22
+ WAITS_FOR_UPGRADE = "it matches its old base, and the bundle's next version replaces it"
23
+ FILE_MISSING = "the file is missing, and the bundle's next version puts it back"
24
+ UNSURE = "an older chi marked it, and the next upgrade checks it"
25
+ NOT_SHIPPED = "not in the bundle now, nothing to merge"
26
+
27
+ # What --force does to a file that stays behind, and to an edited one being removed.
28
+ FORCE_TAKES = "--force takes the bundle's version, your file copied to the trash first"
29
+ FORCE_MOVES = "--force moves it to the trash"
30
+ # Why a file an agent step was given stays behind (BundleCommand#settle_conflicts).
31
+ STILL_BEHIND = { untouched: "Nothing changed in %s: still behind.",
32
+ markers: "%s still holds conflict markers: still behind.",
33
+ incoming_gone: "The bundle's copy of %s is gone: still behind." }.freeze
34
+ # A chi update row's short word for the trash; the path is long for a table.
35
+ IN_THE_TRASH = "old copies: chi bundle trash"
36
+ # How many missed headings a line names.
37
+ SHOWN_MISSED = 3
38
+
39
+ # One file that stays behind. why: the clause saying why no merge was tried
40
+ # (.no_merge, or a plain reason), nil for a real conflict. missed: the
41
+ # headings of the sections the bundle changed. places: the changes under no
42
+ # heading. unsure: an older mark that no retry has looked at, with nothing to
43
+ # compare the file to, so chi can't say it is behind.
44
+ # What a record gives is read as it is shaped: anything else is nothing known.
45
+ Behind = Data.define(:file, :version, :why, :missed, :places, :unsure) do
46
+ def initialize(file:, version: nil, why: nil, missed: [], places: 0, unsure: false)
47
+ super(file: file, version: version, why: why.is_a?(String) ? FileStanding.text(why) : nil,
48
+ missed: (missed.is_a?(Array) ? missed : []).grep(String).map { |heading| FileStanding.text(heading) },
49
+ places: places.is_a?(Integer) && places.positive? ? places : 0, unsure: unsure)
50
+ end
51
+
52
+ # "stays behind 0.8.0: missed ## Objectives"
53
+ def state(dry_run: false)
54
+ return "may be behind#{" #{version}" if version}: #{UNSURE}" if unsure
55
+
56
+ head = "#{dry_run ? "would stay" : "stays"} behind#{" #{version}" if version}"
57
+ rest = [why, FileStanding.missed_words(missed, places)].compact.join("; ")
58
+ rest.empty? ? head : "#{head}: #{rest}"
59
+ end
60
+
61
+ # "x.md stays behind 0.8.0: missed ## Objectives"
62
+ def line(dry_run: false) = "#{FileStanding.text(file)} #{state(dry_run: dry_run)}"
63
+ end
64
+
65
+ # A merge no report has shown yet, as the file's record keeps it
66
+ # (Provenance#note_merged). Every merge leaves one: a worker's reaches nothing
67
+ # but its log, an automatic update's rows no person. It shows in chi bundle
68
+ # status while it exists. A report someone reads ends it: chi update's row, for
69
+ # what it printed (this run's merges too), and chi bundle upgrade's output. No
70
+ # start says it: most sessions never read that file.
71
+ MergeNote = Data.define(:file, :version, :at, :trash) do
72
+ # "merged 0.8.0 into your x.md on 2026-11-05 14:03"
73
+ def line
74
+ "#{FileStanding.merged_line([file], version)}#{" on #{FileStanding.local_time(at)}" if at}"
75
+ end
76
+ end
77
+
78
+ module_function
79
+
80
+ # +value+ as UTF-8, whatever it was tagged, what isn't valid replaced: a file
81
+ # name under LC_ALL=C and a heading must join in one line.
82
+ def text(value) = value.to_s.dup.force_encoding(Encoding::UTF_8).scrub
83
+
84
+ # "merged 0.8.0 into your x.md, y.md"
85
+ def merged_line(files, version, dry_run: false)
86
+ "#{dry_run ? "would merge" : "merged"}#{" #{version}" if version} into your #{files.map { |file| text(file) }.join(", ")}"
87
+ end
88
+
89
+ # The clause for a merge that couldn't answer: +why+ is TextMerge's reason
90
+ # or LEGACY_BASE, where chi can't even say the file holds edits.
91
+ def no_merge(why)
92
+ why == LEGACY_BASE ? "chi can't tell whether it holds edits of yours (#{why})" : "no merge possible (#{why})"
93
+ end
94
+
95
+ def still_behind(reason, file) = format(STILL_BEHIND.fetch(reason), text(file))
96
+
97
+ def left_as_it_is(message) = "left as it is (#{message})"
98
+
99
+ # "missed ## A, ## B, ## C and 2 more", "missed 2 places"; nil for nothing known.
100
+ def missed_words(headings, places)
101
+ return places.positive? ? "missed #{counted(places, "place")}" : nil if headings.empty?
102
+
103
+ more = [headings.size - SHOWN_MISSED, 0].max + places
104
+ words = "missed #{headings.first(SHOWN_MISSED).join(", ")}"
105
+ more.positive? ? "#{words} and #{more} more" : words
106
+ end
107
+
108
+ # "1 file behind"; "2 files may be behind" if chi isn't sure of one of them.
109
+ def behind_count(count, unsure: false) = "#{counted(count, "file")} #{"may be " if unsure}behind"
110
+
111
+ # "1 file stays behind", "2 files stay behind"; "1 file may be behind"
112
+ def stay_behind(count, unsure: false)
113
+ return behind_count(count, unsure: true) if unsure
114
+
115
+ "#{counted(count, "file")} #{count == 1 ? "stays" : "stay"} behind"
116
+ end
117
+
118
+ # The sentence of a push, merged and behind as counts: "" for neither.
119
+ def counts_sentence(merged:, behind:)
120
+ parts = []
121
+ parts << "Merged the update into #{counted(merged, "file")} you edited." if merged.to_i.positive?
122
+ parts << "#{stay_behind(behind)}." if behind.to_i.positive?
123
+ parts.join(" ")
124
+ end
125
+
126
+ # A report's clauses for one bundle: what merged, what stays behind with
127
+ # how to see it, what an earlier merge did.
128
+ # @param merged [Array<String>] files
129
+ # @param behind [Array<Behind>]
130
+ # @param earlier [Array<MergeNote>]
131
+ # @return [Array<String>]
132
+ def clauses(bundle, version, merged: [], behind: [], earlier: [], dry_run: false)
133
+ behind_lines = behind.map { |file| file.line(dry_run: dry_run) }
134
+ behind_lines[0] += " (#{diff_hint(bundle, behind.first.file)})" unless behind.empty?
135
+ [*(merged.empty? ? nil : merged_line(merged, version, dry_run: dry_run)), *behind_lines, *earlier.map(&:line)]
136
+ end
137
+
138
+ def diff_hint(bundle, file) = "chi bundle diff #{text(bundle)} #{text(file)}"
139
+
140
+ def local_time(stamp)
141
+ Time.iso8601(stamp.to_s).localtime.strftime("%Y-%m-%d %H:%M")
142
+ rescue ArgumentError
143
+ stamp.to_s
144
+ end
145
+
146
+ # The one answer to whether a file stays behind, for a report and a retry
147
+ # alike: marked, there, and not level with the bundle's text the mark speaks
148
+ # of. A missing file is a missing file (a project-scope record doesn't even
149
+ # say which project it means).
150
+ # @param incoming_sha [String, nil] that text's digest, for a mark with none
151
+ def behind?(path, entry, incoming_sha: entry[:incoming_sha])
152
+ entry[:conflict] == true && File.exist?(path) && differs?(path, incoming_sha)
153
+ end
154
+
155
+ # The file's bytes aren't what the mark says the bundle ships. One it can't
156
+ # read isn't known to be level. No digest (an older mark): it isn't known
157
+ # either, and a retry would have settled it.
158
+ def differs?(path, incoming_sha)
159
+ incoming_sha.nil? || Digest::SHA256.file(path).hexdigest != incoming_sha
160
+ rescue SystemCallError
161
+ true
162
+ end
163
+
164
+ def counted(count, noun) = "#{count} #{noun}#{"s" unless count == 1}"
165
+ end
166
+ end
167
+ end
@@ -1,24 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "json"
5
+ require_relative "../memory_paths"
4
6
  require_relative "bundle_hook"
5
7
  require_relative "plugin_ref"
6
8
 
7
9
  module Samagotchi
8
10
  module MemoryBundle
11
+ # Only what reports on an edited file needs it.
12
+ autoload :FileStanding, File.expand_path("file_standing", __dir__)
13
+
9
14
  # An installed bundle's record, as Provenance#write keeps it in
10
15
  # <bundles>/<name>/manifest.json. Reading is tolerant: an older record
11
16
  # lacks keys a newer chi writes, and a key that is missing or of the
12
17
  # wrong shape reads as empty (nil for the optional strings and lists).
13
18
  #
14
- # files file key ("x.md") => { checksum:, conflict:, description: } (symbol keys)
19
+ # files file key ("x.md") => { checksum:, conflict:, incoming_sha:, legacy_base:, why:, missed:, missed_places:,
20
+ # description: } (symbol keys);
21
+ # incoming_sha: the bundle text a conflict mark speaks of, nil on an older mark;
22
+ # legacy_base: true on an older mark that has since got its incoming_sha (.legacy_mark?);
23
+ # why, missed, missed_places: FileStanding::Behind's, nil on an older mark;
24
+ # merged_version, merged_at, merged_trash: a merge no report has shown (Provenance#note_merged)
15
25
  # hooks basename => BundleHook
16
26
  # guardrails basename => recorded sha256 ("" when none)
17
27
  # scripts file name => recorded sha256 ("" when none)
18
28
  # plugin PluginRef, nil without one
19
- # needs, context_providers
20
- # the lists as recorded (nil when absent): Manifest.parse_needs
21
- # and ContextProviders.parse_list check them
29
+ # needs, context_providers, block_renderers
30
+ # the lists as recorded (nil when absent): Manifest.parse_needs,
31
+ # ContextProviders.parse_list and BlockRenderers.parse_list check them
22
32
  # includes a profile's recorded members, nil for a plain bundle
23
33
  # session_settings
24
34
  # the keys a session may set (chi --set), [] when none
@@ -33,18 +43,24 @@ module Samagotchi
33
43
  # error why manifest.json couldn't be read (the rest empty), else nil
34
44
  class InstalledBundle < Data.define(:name, :version, :scope, :source, :installed_at, :trust_level, :source_commit,
35
45
  :requires_chi, :files, :hooks, :guardrails, :scripts, :plugin, :needs,
36
- :includes, :context_providers, :session_settings, :session_setting_types, :settings,
46
+ :includes, :context_providers, :block_renderers, :session_settings, :session_setting_types, :settings,
37
47
  :setting_types, :dev,
38
48
  :error)
39
49
  OPTIONAL = { version: nil, scope: nil, source: nil, installed_at: nil, trust_level: nil, source_commit: nil,
40
50
  requires_chi: nil, files: {}, hooks: {}, guardrails: {}, scripts: {}, plugin: nil, needs: nil,
41
- includes: nil, context_providers: nil, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {}, dev: false,
51
+ includes: nil, context_providers: nil, block_renderers: nil, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {}, dev: false,
42
52
  error: nil }.freeze
43
53
 
44
54
  def initialize(name:, **fields)
45
55
  super(name: name, **OPTIONAL, **fields)
46
56
  end
47
57
 
58
+ # Whether a file's entry is a conflict mark from an older chi: no
59
+ # incoming_sha, or legacy_base. Such a mark's base may be the user's own
60
+ # text (0.149 took it for a file with no base). Getting its digest later
61
+ # must not make it look new, so legacy_base stays until the mark goes.
62
+ def self.legacy_mark?(entry) = !!entry && entry[:conflict] == true && (entry[:legacy_base] == true || !entry[:incoming_sha])
63
+
48
64
  # The record at +path+, nil when there is none.
49
65
  # @raise [JSON::ParserError, SystemCallError] it doesn't read
50
66
  def self.read(name, path)
@@ -70,6 +86,7 @@ module Samagotchi
70
86
  guardrails: mapping(h["guardrails"]) { |meta| sha(meta) },
71
87
  scripts: mapping(h["scripts"]) { |meta| sha(meta) },
72
88
  plugin: PluginRef.parse(h["plugin"]), needs: h["needs"], context_providers: h["context_providers"],
89
+ block_renderers: h["block_renderers"],
73
90
  includes: h["includes"].is_a?(Array) ? h["includes"].map(&:to_s) : nil,
74
91
  session_settings: h["session_settings"].is_a?(Array) ? h["session_settings"].map(&:to_s) : [],
75
92
  session_setting_types: mapping(h["session_setting_types"]) { |type| type.to_s },
@@ -100,9 +117,56 @@ module Samagotchi
100
117
 
101
118
  def owns?(file_key) = files.key?(file_key.to_s)
102
119
 
120
+ # The plugin's file => its recorded sha256, as guardrails and scripts read.
121
+ def plugin_shas = plugin ? { plugin.file => plugin.sha256 } : {}
122
+
123
+ # The marked files that stay behind: one that isn't there is a missing file,
124
+ # and one with the bundle's bytes has nothing left to miss.
125
+ # @param target_dir [String, nil] where its files are; a scope this chi can't
126
+ # resolve has none, and nothing is known of them
127
+ # @return [Array<FileStanding::Behind>]
128
+ def behind_files(target_dir = MemoryPaths.scope_dir(effective_scope))
129
+ return [] unless target_dir
130
+
131
+ files.filter_map { |key, entry| behind_entry(key, entry) if behind_file?(key, File.join(target_dir, key)) }
132
+ end
133
+
134
+ # FileStanding.behind? for this record's file. An older mark names no text,
135
+ # but a source still on disk holds it: level with that, it has missed nothing.
136
+ def behind_file?(key, path)
137
+ entry = files.fetch(key)
138
+ FileStanding.behind?(path, entry, incoming_sha: entry[:incoming_sha] || source_sha(key))
139
+ end
140
+
141
+ # The merges no chi update has shown yet.
142
+ # @return [Array<FileStanding::MergeNote>]
143
+ def merge_notes
144
+ files.filter_map do |key, entry|
145
+ next unless entry[:merged_version]
146
+
147
+ FileStanding::MergeNote.new(file: key, version: entry[:merged_version], at: entry[:merged_at], trash: entry[:merged_trash])
148
+ end
149
+ end
150
+
151
+ # The file's mark as a report reads it.
152
+ def behind_entry(key, entry = files.fetch(key))
153
+ FileStanding::Behind.new(file: key, version: version, why: entry[:why], missed: entry[:missed], places: entry[:missed_places],
154
+ unsure: !entry[:incoming_sha] && !entry[:why])
155
+ end
156
+
157
+ def source_sha(key)
158
+ path = File.join(source.to_s, key)
159
+ File.file?(path) ? Digest::SHA256.file(path).hexdigest : nil
160
+ rescue SystemCallError
161
+ nil
162
+ end
163
+ private :source_sha
164
+
103
165
  def needs? = needs.is_a?(Array) && !needs.empty?
104
166
 
105
167
  def context_providers? = context_providers.is_a?(Array) && !context_providers.empty?
168
+
169
+ def block_renderers? = block_renderers.is_a?(Array) && !block_renderers.empty?
106
170
  end
107
171
  end
108
172
  end
@@ -42,6 +42,7 @@ module Samagotchi
42
42
  requires_chi: bundle.requires_chi, needs: bundle.needs,
43
43
  # A record read with symbol keys: back to what a manifest.yml holds.
44
44
  context_providers: JSON.parse(JSON.generate(Array(bundle.context_providers))),
45
+ block_renderers: JSON.parse(JSON.generate(Array(bundle.block_renderers))),
45
46
  session_settings: bundle.session_settings, session_setting_types: bundle.session_setting_types,
46
47
  settings: bundle.settings, setting_types: bundle.setting_types
47
48
  )