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
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:,
|
|
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
|
|
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
|
)
|