samagotchi 0.148.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -1
- data/README.md +4 -2
- data/docs/architecture.md +3 -3
- data/docs/bundles.md +38 -1
- data/docs/cli.md +76 -24
- data/docs/configuration.md +8 -6
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +2 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bridge.md +8 -0
- data/docs/internals/bundles.md +42 -14
- data/docs/internals/objectives.md +269 -0
- data/docs/internals/web-frontend.md +4 -1
- data/docs/internals/web-push.md +2 -2
- data/docs/internals/working-set.md +5 -2
- data/docs/memory.md +47 -9
- data/docs/plugins.md +10 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +45 -2
- data/docs/system-one.md +17 -1
- data/docs/testing.md +5 -0
- data/lib/samagotchi/atomic_file.rb +3 -0
- data/lib/samagotchi/bootstrap_command.rb +5 -2
- data/lib/samagotchi/bridge/card_store.rb +52 -7
- data/lib/samagotchi/bridge.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +91 -68
- data/lib/samagotchi/bundler_env.rb +24 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +6 -4
- data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +9 -0
- data/lib/samagotchi/bundles/decision-card/manifest.yml +13 -0
- data/lib/samagotchi/bundles/decision-card/plugin.rb +280 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -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/config.rb +7 -1
- data/lib/samagotchi/context_note.rb +17 -5
- data/lib/samagotchi/engine.rb +113 -6
- data/lib/samagotchi/engine_peers.rb +11 -0
- data/lib/samagotchi/git_env.rb +43 -0
- data/lib/samagotchi/guardrails/context.rb +2 -0
- data/lib/samagotchi/guardrails/pair_boundary.rb +3 -2
- data/lib/samagotchi/guardrails/targets.rb +4 -1
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/memory_bundle/asset_installer.rb +16 -2
- data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +62 -1
- data/lib/samagotchi/memory_bundle/installer.rb +161 -52
- data/lib/samagotchi/memory_bundle/listing.rb +4 -3
- 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 +178 -20
- 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/note_command.rb +1 -1
- data/lib/samagotchi/note_delivery.rb +6 -2
- data/lib/samagotchi/objectives.rb +395 -0
- data/lib/samagotchi/objectives_audit.rb +115 -0
- data/lib/samagotchi/objectives_check.rb +365 -0
- data/lib/samagotchi/objectives_command.rb +192 -0
- data/lib/samagotchi/plugin/api.rb +2 -1
- data/lib/samagotchi/plugin/context.rb +21 -1
- data/lib/samagotchi/project_env.rb +3 -2
- data/lib/samagotchi/self_report.rb +2 -0
- data/lib/samagotchi/session_commands.rb +15 -0
- data/lib/samagotchi/session_continue.rb +3 -0
- data/lib/samagotchi/session_inbox.rb +14 -6
- data/lib/samagotchi/session_manager.rb +1 -1
- data/lib/samagotchi/steer.rb +24 -4
- data/lib/samagotchi/testing/boundary.rb +2 -0
- data/lib/samagotchi/testing/host.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/tool_activity.rb +17 -0
- data/lib/samagotchi/tool_declarations.rb +34 -0
- data/lib/samagotchi/tools/builtin_calls.rb +2 -1
- data/lib/samagotchi/tools/builtins.rb +4 -1
- data/lib/samagotchi/tools/delegate.rb +2 -1
- data/lib/samagotchi/tools/objectives_tool.rb +75 -0
- data/lib/samagotchi/tools/peers.rb +3 -0
- data/lib/samagotchi/tools/task_runtime.rb +13 -10
- data/lib/samagotchi/turn_flow.rb +3 -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/auto_update.rb +8 -8
- data/lib/samagotchi/web/auto_update_record.rb +6 -4
- data/lib/samagotchi/web/message_display.rb +9 -0
- data/lib/samagotchi/web/participant_view.rb +2 -2
- data/lib/samagotchi/web/public/app.js +15 -3
- data/lib/samagotchi/web/public/block_open.js +13 -0
- data/lib/samagotchi/web/public/bubbles.js +4 -2
- data/lib/samagotchi/web/public/cards_place.js +30 -9
- data/lib/samagotchi/web/public/commands.js +4 -1
- data/lib/samagotchi/web/public/details_motion.js +2 -1
- data/lib/samagotchi/web/public/index.html +20 -14
- data/lib/samagotchi/web/public/objectives.css +80 -0
- data/lib/samagotchi/web/public/objectives_entry.js +22 -0
- data/lib/samagotchi/web/public/objectives_model.js +100 -0
- data/lib/samagotchi/web/public/objectives_moves.js +145 -0
- data/lib/samagotchi/web/public/objectives_strip.js +242 -0
- data/lib/samagotchi/web/public/objectives_wiring.js +62 -0
- 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/stream_handlers.js +6 -1
- 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 +12 -1
- data/lib/samagotchi/web/update_ops.rb +2 -1
- data/lib/samagotchi/worker.rb +3 -0
- data/lib/samagotchi/worker_inbound.rb +16 -8
- metadata +22 -1
|
@@ -1,17 +1,27 @@
|
|
|
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)
|
|
@@ -45,6 +55,12 @@ module Samagotchi
|
|
|
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)
|
|
@@ -100,6 +116,51 @@ module Samagotchi
|
|
|
100
116
|
|
|
101
117
|
def owns?(file_key) = files.key?(file_key.to_s)
|
|
102
118
|
|
|
119
|
+
# The plugin's file => its recorded sha256, as guardrails and scripts read.
|
|
120
|
+
def plugin_shas = plugin ? { plugin.file => plugin.sha256 } : {}
|
|
121
|
+
|
|
122
|
+
# The marked files that stay behind: one that isn't there is a missing file,
|
|
123
|
+
# and one with the bundle's bytes has nothing left to miss.
|
|
124
|
+
# @param target_dir [String, nil] where its files are; a scope this chi can't
|
|
125
|
+
# resolve has none, and nothing is known of them
|
|
126
|
+
# @return [Array<FileStanding::Behind>]
|
|
127
|
+
def behind_files(target_dir = MemoryPaths.scope_dir(effective_scope))
|
|
128
|
+
return [] unless target_dir
|
|
129
|
+
|
|
130
|
+
files.filter_map { |key, entry| behind_entry(key, entry) if behind_file?(key, File.join(target_dir, key)) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# FileStanding.behind? for this record's file. An older mark names no text,
|
|
134
|
+
# but a source still on disk holds it: level with that, it has missed nothing.
|
|
135
|
+
def behind_file?(key, path)
|
|
136
|
+
entry = files.fetch(key)
|
|
137
|
+
FileStanding.behind?(path, entry, incoming_sha: entry[:incoming_sha] || source_sha(key))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The merges no chi update has shown yet.
|
|
141
|
+
# @return [Array<FileStanding::MergeNote>]
|
|
142
|
+
def merge_notes
|
|
143
|
+
files.filter_map do |key, entry|
|
|
144
|
+
next unless entry[:merged_version]
|
|
145
|
+
|
|
146
|
+
FileStanding::MergeNote.new(file: key, version: entry[:merged_version], at: entry[:merged_at], trash: entry[:merged_trash])
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The file's mark as a report reads it.
|
|
151
|
+
def behind_entry(key, entry = files.fetch(key))
|
|
152
|
+
FileStanding::Behind.new(file: key, version: version, why: entry[:why], missed: entry[:missed], places: entry[:missed_places],
|
|
153
|
+
unsure: !entry[:incoming_sha] && !entry[:why])
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def source_sha(key)
|
|
157
|
+
path = File.join(source.to_s, key)
|
|
158
|
+
File.file?(path) ? Digest::SHA256.file(path).hexdigest : nil
|
|
159
|
+
rescue SystemCallError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
private :source_sha
|
|
163
|
+
|
|
103
164
|
def needs? = needs.is_a?(Array) && !needs.empty?
|
|
104
165
|
|
|
105
166
|
def context_providers? = context_providers.is_a?(Array) && !context_providers.empty?
|
|
@@ -34,6 +34,9 @@ module Samagotchi
|
|
|
34
34
|
|
|
35
35
|
attr_reader :results, :warnings, :placeholder_warnings, :conflicts
|
|
36
36
|
|
|
37
|
+
# Each file this run left behind (FileStanding::Behind).
|
|
38
|
+
attr_reader :behind
|
|
39
|
+
|
|
37
40
|
# prepared: the source already normalized (a PreparedSource, which the
|
|
38
41
|
# Installer then owns and cleans up as its own); nil normalizes source.
|
|
39
42
|
# shipped_dir: the bundles this chi ships (a bundle of their names
|
|
@@ -55,6 +58,9 @@ module Samagotchi
|
|
|
55
58
|
@warnings = []
|
|
56
59
|
@placeholder_warnings = []
|
|
57
60
|
@conflicts = {}
|
|
61
|
+
@behind = []
|
|
62
|
+
@replaced = []
|
|
63
|
+
@outcomes = {}
|
|
58
64
|
@overlays = []
|
|
59
65
|
@trash = Trash.new(name)
|
|
60
66
|
end
|
|
@@ -63,10 +69,16 @@ module Samagotchi
|
|
|
63
69
|
# source (Installer#check_source), else nil.
|
|
64
70
|
attr_reader :source_change
|
|
65
71
|
|
|
66
|
-
# Where
|
|
67
|
-
# none
|
|
72
|
+
# Where this install moved the files the bundle no longer ships and
|
|
73
|
+
# copied the ones it overwrote (nil: none).
|
|
68
74
|
def trash_dir = @trash.dir
|
|
69
75
|
|
|
76
|
+
# What it copied to the trash before overwriting, said once (nil: nothing).
|
|
77
|
+
def trash_copy_line = @trash.copy_line
|
|
78
|
+
|
|
79
|
+
# The files this run merged, the bundle's changes joined to the user's.
|
|
80
|
+
def merged_files = @results.select { |_, r| r[:status] == "merged" }.keys
|
|
81
|
+
|
|
70
82
|
# The normalized source dir. An owned one (a git/zip/tar extract) is
|
|
71
83
|
# removed after #run, or kept until #cleanup_source! when an upgrade
|
|
72
84
|
# has conflicts (the agent step reads the incoming files); nil once
|
|
@@ -94,16 +106,7 @@ module Samagotchi
|
|
|
94
106
|
check_source
|
|
95
107
|
# A plain install over an installed bundle: summary points at upgrade.
|
|
96
108
|
@reinstall = @existing && !@upgrade && !@force && !@dry_run
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
installed = AssetInstaller.new(
|
|
100
|
-
name: @name, source_dir: @bundle_dir, manifest: @manifest, provenance: @provenance, existing: @existing,
|
|
101
|
-
results: @results, warnings: @warnings, conflicts: @conflicts,
|
|
102
|
-
force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run, dev: @dev
|
|
103
|
-
).run
|
|
104
|
-
|
|
105
|
-
# Files the previous version shipped and this one doesn't.
|
|
106
|
-
prune_dropped_files(@existing.files, @bundle_md, @target_dir, @target_scope, @provenance) if @upgrade && @existing
|
|
109
|
+
installed = replace_files
|
|
107
110
|
verify_memory_checksums if @manifest && @strict
|
|
108
111
|
warn_missing_needs(@manifest) if @manifest
|
|
109
112
|
if @manifest && !@dry_run
|
|
@@ -113,6 +116,7 @@ module Samagotchi
|
|
|
113
116
|
|
|
114
117
|
[@bundle_dir, @manifest]
|
|
115
118
|
ensure
|
|
119
|
+
note_merges
|
|
116
120
|
# An owned source (git/zip/tar) is cleaned up here, unless an upgrade
|
|
117
121
|
# kept conflicts: the agent step still needs the incoming files, so
|
|
118
122
|
# the caller runs #cleanup_source! when it is done.
|
|
@@ -131,9 +135,10 @@ module Samagotchi
|
|
|
131
135
|
lines << "Source changes: #{@source_change.join(" → ")}" if @source_change
|
|
132
136
|
installed = @results.select { |_, r| r[:status].to_s == "installed" }.keys
|
|
133
137
|
updated = @results.select { |_, r| r[:status].to_s == "updated" }.keys
|
|
134
|
-
|
|
138
|
+
merged = merged_files
|
|
139
|
+
would_merge = @results.select { |_, r| r[:status].to_s == "would_merge" }.keys
|
|
140
|
+
kept = @results.select { |_, r| %w[kept keep kept_pruned].include?(r[:status].to_s) }.keys
|
|
135
141
|
skipped = @results.select { |_, r| %w[skipped noop would_skip].include?(r[:status].to_s) }.keys
|
|
136
|
-
conflicts = @results.select { |_, r| r[:status].to_s == "conflict" }.keys
|
|
137
142
|
would_install = @results.select { |_, r| r[:status].to_s == "would_install" }.keys
|
|
138
143
|
fast_forward = @results.select { |_, r| r[:status].to_s == "fast_forward" }.keys
|
|
139
144
|
removed = @results.select { |_, r| r[:status].to_s == "removed" }.keys
|
|
@@ -143,11 +148,13 @@ module Samagotchi
|
|
|
143
148
|
lines << "Updated: #{updated.join(", ")}" unless updated.empty?
|
|
144
149
|
lines << "Would install: #{would_install.join(", ")}" unless would_install.empty?
|
|
145
150
|
lines << "Fast-forward: #{fast_forward.join(", ")}" unless fast_forward.empty?
|
|
151
|
+
lines << FileStanding.merged_line(merged, @manifest&.version) unless merged.empty?
|
|
152
|
+
lines << FileStanding.merged_line(would_merge, @manifest&.version, dry_run: true) unless would_merge.empty?
|
|
146
153
|
lines << "Removed (no longer in the bundle): #{removed.join(", ")}#{" (moved to #{trash_dir})" if trash_dir}" unless removed.empty?
|
|
147
154
|
lines << "Would remove (no longer in the bundle): #{would_remove.join(", ")}" unless would_remove.empty?
|
|
148
155
|
lines << "Kept (local edits preserved): #{kept.join(", ")}" unless kept.empty?
|
|
149
156
|
lines << "Skipped: #{skipped.join(", ")}" unless skipped.empty?
|
|
150
|
-
lines <<
|
|
157
|
+
lines << trash_copy_line if trash_copy_line
|
|
151
158
|
lines.concat(@warnings) unless @warnings.empty?
|
|
152
159
|
lines.concat(@placeholder_warnings) unless @placeholder_warnings.empty?
|
|
153
160
|
lines << "#{@name} is already installed; `chi bundle upgrade #{@name}` updates it and keeps local edits" if @reinstall
|
|
@@ -157,6 +164,28 @@ module Samagotchi
|
|
|
157
164
|
|
|
158
165
|
private
|
|
159
166
|
|
|
167
|
+
# The steps that replace files: memories, the bundle's own folder, then
|
|
168
|
+
# the files it dropped. The record isn't written yet, so a failed run is
|
|
169
|
+
# retried as the same upgrade.
|
|
170
|
+
# @return [AssetInstaller::Installed]
|
|
171
|
+
# @raise [InstallError] a file system error (a file it can't read, a
|
|
172
|
+
# .trash it can't write)
|
|
173
|
+
def replace_files
|
|
174
|
+
install_memories
|
|
175
|
+
|
|
176
|
+
installed = AssetInstaller.new(
|
|
177
|
+
name: @name, source_dir: @bundle_dir, manifest: @manifest, provenance: @provenance, existing: @existing,
|
|
178
|
+
results: @results, warnings: @warnings, conflicts: @conflicts, trash: @trash,
|
|
179
|
+
force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run, dev: @dev
|
|
180
|
+
).run
|
|
181
|
+
|
|
182
|
+
# Files the previous version shipped and this one doesn't.
|
|
183
|
+
prune_dropped_files(@existing.files, @bundle_md, @target_dir, @target_scope, @provenance) if @upgrade && @existing
|
|
184
|
+
installed
|
|
185
|
+
rescue SystemCallError => e
|
|
186
|
+
raise InstallError, Trash.failure_message(e)
|
|
187
|
+
end
|
|
188
|
+
|
|
160
189
|
# Who may install under this name (a manifest can take any name: a
|
|
161
190
|
# repo calling itself guardrails). Sources compare by SourceIdentity
|
|
162
191
|
# (a shipped dir by name, in any gem or checkout; a git URL without
|
|
@@ -314,8 +343,12 @@ module Samagotchi
|
|
|
314
343
|
@owned << file_key if owned_before.include?(file_key)
|
|
315
344
|
skip_existing(file_key, file_path, target_path, @target_scope, owned: owned_before.include?(file_key))
|
|
316
345
|
else
|
|
346
|
+
# Only --force gets here with a file in place: a copy keeps it.
|
|
347
|
+
@replaced << file_key if File.exist?(target_path)
|
|
348
|
+
@trash.copy(target_path) if File.exist?(target_path) && !FileUtils.identical?(file_path, target_path)
|
|
317
349
|
FileUtils.cp(file_path, target_path)
|
|
318
350
|
@owned << file_key
|
|
351
|
+
@outcomes[file_key] = Merger::INSTALL
|
|
319
352
|
@results[file_key] = { status: "installed" }
|
|
320
353
|
update_target_index(@target_scope, target_path, file_key)
|
|
321
354
|
end
|
|
@@ -338,30 +371,71 @@ module Samagotchi
|
|
|
338
371
|
MemoryFiles.symlinks(@bundle_dir, @manifest).each { |name| @warnings << "#{name} is a symlink (not installed)" }
|
|
339
372
|
end
|
|
340
373
|
|
|
341
|
-
# An upgrade's 3-way merge of one file (Merger
|
|
342
|
-
# base the last install recorded); a
|
|
343
|
-
# and is recorded in @conflicts for the agent step.
|
|
374
|
+
# An upgrade's 3-way merge of one file (Merger::Upgrade against the
|
|
375
|
+
# base the last install recorded); a clean text merge replaces the file;
|
|
376
|
+
# a conflict leaves it and is recorded in @conflicts for the agent step.
|
|
344
377
|
def merge_memory(file_key, file_path, target_path)
|
|
345
|
-
|
|
346
|
-
case
|
|
378
|
+
outcome = @outcomes[file_key] = upgrade_view.outcome(file_key)
|
|
379
|
+
case outcome.status
|
|
347
380
|
when :install
|
|
348
381
|
FileUtils.cp(file_path, target_path)
|
|
349
382
|
@results[file_key] = { status: "installed" }
|
|
350
383
|
when :fast_forward
|
|
384
|
+
save_marked_file(file_key, target_path)
|
|
351
385
|
FileUtils.cp(file_path, target_path)
|
|
352
386
|
@results[file_key] = { status: "updated", reason: "auto-merged (not edited)" }
|
|
353
387
|
when :keep
|
|
354
|
-
@results[file_key] = { status: "kept", reason: "bundle unchanged, local edits preserved" }
|
|
388
|
+
@results[file_key] = { status: "kept", reason: outcome == Merger::KEEP ? "bundle unchanged, local edits preserved" : "local edits already hold the bundle's changes" }
|
|
355
389
|
when :noop
|
|
356
390
|
@results[file_key] = { status: "skipped", reason: "already up to date" }
|
|
391
|
+
when :merged
|
|
392
|
+
if Merger.write_merged(target_path, outcome, @trash)
|
|
393
|
+
@results[file_key] = { status: "merged", reason: "the bundle's changes joined to local edits" }
|
|
394
|
+
else
|
|
395
|
+
note_conflict(file_key, @outcomes[file_key] = Merger::CONFLICT.with(merge: TextMerge.failed(TextMerge::RACED)))
|
|
396
|
+
end
|
|
357
397
|
when :conflict
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
@warnings << "Conflict in #{file_key}: local edits conflict with bundle update (use --force to overwrite or resolve interactively)"
|
|
398
|
+
note_conflict(file_key, outcome)
|
|
399
|
+
else raise ArgumentError, "unknown outcome status #{outcome.status.inspect} for #{file_key}" # the base would move with nothing written
|
|
361
400
|
end
|
|
362
401
|
update_target_index(@target_scope, target_path, file_key)
|
|
363
402
|
end
|
|
364
403
|
|
|
404
|
+
# The file stays as it is, with the old base and the mark, in FileStanding's
|
|
405
|
+
# words.
|
|
406
|
+
def note_conflict(file_key, outcome)
|
|
407
|
+
behind = upgrade_view.behind(file_key, outcome, @manifest&.version)
|
|
408
|
+
@behind << behind
|
|
409
|
+
@results[file_key] = { status: "conflict", reason: behind.state(dry_run: @dry_run) }
|
|
410
|
+
@conflicts[file_key] = upgrade_view.paths(file_key)
|
|
411
|
+
@warnings << "#{behind.line(dry_run: @dry_run)} (#{FileStanding::FORCE_TAKES})"
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# What merged, on the record, a run that failed later too: the file holds the
|
|
415
|
+
# merge whichever way the run ended. Never fails the run.
|
|
416
|
+
def note_merges
|
|
417
|
+
return if merged_files.empty?
|
|
418
|
+
|
|
419
|
+
@provenance.note_merged(merged_files, version: @manifest&.version, trash: trash_dir)
|
|
420
|
+
rescue StandardError => e
|
|
421
|
+
@warnings << "merge not noted on the record (#{e.message})"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def upgrade_view
|
|
425
|
+
@upgrade_view ||= Merger::Upgrade.new(provenance: @provenance, target_dir: @target_dir, source_dir: @bundle_dir)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# A marked file that equals its base, and the update goes ahead: the base
|
|
429
|
+
# is known to be real (this chi's mark, or ShippedHistory), or no line of the
|
|
430
|
+
# file goes. A copy is kept, and it says so. (Merger::Upgrade#outcome stops
|
|
431
|
+
# an older chi's mark otherwise.)
|
|
432
|
+
def save_marked_file(file_key, target_path)
|
|
433
|
+
return unless @existing.files.dig(file_key, :conflict)
|
|
434
|
+
|
|
435
|
+
@trash.copy(target_path)
|
|
436
|
+
@warnings << "#{file_key} was marked as kept but matched its recorded base, so it took the update; your copy is in the trash"
|
|
437
|
+
end
|
|
438
|
+
|
|
365
439
|
# A dry run's verdict for one file, nothing written: an upgrade
|
|
366
440
|
# classifies as merge_memory would (its status is the classification).
|
|
367
441
|
def preview_memory(file_key, file_path, target_path)
|
|
@@ -371,10 +445,10 @@ module Samagotchi
|
|
|
371
445
|
end
|
|
372
446
|
|
|
373
447
|
if @upgrade && @existing
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
@
|
|
448
|
+
outcome = upgrade_view.outcome(file_key)
|
|
449
|
+
return note_conflict(file_key, outcome) if outcome.conflict?
|
|
450
|
+
|
|
451
|
+
@results[file_key] = { status: outcome.merged? ? "would_merge" : outcome.status.to_s }
|
|
378
452
|
elsif FileUtils.identical?(file_path, target_path)
|
|
379
453
|
@results[file_key] = { status: "skipped", reason: "already up to date" }
|
|
380
454
|
else
|
|
@@ -383,14 +457,15 @@ module Samagotchi
|
|
|
383
457
|
end
|
|
384
458
|
|
|
385
459
|
# Strict mode: a memory written from the bundle whose sha256 differs
|
|
386
|
-
# from its manifest's warns. One that wasn't copied (kept, skipped
|
|
387
|
-
# a local edit isn't a bad download) or
|
|
460
|
+
# from its manifest's warns. One that wasn't copied (kept, skipped, a
|
|
461
|
+
# dry run's keep and would_skip: a local edit isn't a bad download) or
|
|
462
|
+
# was merged (merged, a dry run's would_merge) isn't checked.
|
|
388
463
|
def verify_memory_checksums
|
|
389
464
|
@bundle_md.each do |file_key|
|
|
390
465
|
next if @conflicts.key?(file_key)
|
|
391
466
|
|
|
392
467
|
status = @results[file_key] ? @results[file_key][:status].to_s : nil
|
|
393
|
-
next if %w[kept kept_pruned fast_forward updated skipped].include?(status)
|
|
468
|
+
next if %w[kept keep kept_pruned fast_forward updated skipped would_skip merged would_merge].include?(status)
|
|
394
469
|
|
|
395
470
|
target_path = File.join(@target_dir, file_key)
|
|
396
471
|
next unless File.exist?(target_path)
|
|
@@ -437,7 +512,8 @@ module Samagotchi
|
|
|
437
512
|
plugin_file: installed.plugin_file,
|
|
438
513
|
requires_chi: @manifest.requires_chi,
|
|
439
514
|
needs: @manifest.needs,
|
|
440
|
-
|
|
515
|
+
marks: @behind + held_conflict_marks,
|
|
516
|
+
incoming_dir: @bundle_dir,
|
|
441
517
|
scripts_files: installed.script_files,
|
|
442
518
|
context_providers: @manifest.context_providers,
|
|
443
519
|
file_descriptions: @manifest.file_descriptions,
|
|
@@ -447,34 +523,69 @@ module Samagotchi
|
|
|
447
523
|
setting_types: @manifest.setting_types,
|
|
448
524
|
dev: @dev
|
|
449
525
|
)
|
|
526
|
+
# Replaced by --force: an earlier merge into it no longer says anything true.
|
|
527
|
+
@provenance.clear_merge_notes([], files: @replaced)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# The files a plain install skipped that are still marked conflict:
|
|
531
|
+
# Provenance#write rebuilds their entries, and a file it left alone has
|
|
532
|
+
# missed the bundle's change as much as before (its version may move on
|
|
533
|
+
# all the same). One with the incoming bytes has nothing left to miss.
|
|
534
|
+
# @return [Array<FileStanding::Behind>]
|
|
535
|
+
def held_conflict_marks
|
|
536
|
+
return [] if @upgrade || @existing.nil?
|
|
537
|
+
|
|
538
|
+
kept = @owned.select { |key| @results.dig(key, :reason) == "already exists" && @existing.files.dig(key, :conflict) }
|
|
539
|
+
kept.map { |key| held_mark(key) }
|
|
450
540
|
end
|
|
451
541
|
|
|
452
|
-
#
|
|
453
|
-
#
|
|
454
|
-
#
|
|
455
|
-
|
|
456
|
-
|
|
542
|
+
# The mark now speaks of this source's text, so what it says comes from that
|
|
543
|
+
# text too: what was missed recomputed, an old reason dropped. No merge ran,
|
|
544
|
+
# so one from an older chi, which can't name sections, keeps its own words.
|
|
545
|
+
def held_mark(key)
|
|
546
|
+
entry = @existing.files.fetch(key)
|
|
547
|
+
return @existing.behind_entry(key, entry).with(version: @manifest.version) if InstalledBundle.legacy_mark?(entry)
|
|
548
|
+
|
|
549
|
+
upgrade_view.behind(key, Merger::CONFLICT, @manifest.version)
|
|
550
|
+
rescue SystemCallError
|
|
551
|
+
FileStanding::Behind.new(file: key, version: @manifest.version)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# file key => the file whose content becomes its base: the one its
|
|
555
|
+
# Merger::Outcome names (next_base). A file the bundle dropped but the
|
|
556
|
+
# user kept stays recorded too, with the base it has.
|
|
457
557
|
def upgrade_provenance_files
|
|
458
558
|
files = {}
|
|
459
559
|
@owned.each do |file_key|
|
|
460
|
-
|
|
461
|
-
next unless File.exist?(target_path)
|
|
560
|
+
next unless File.exist?(File.join(@target_dir, file_key))
|
|
462
561
|
|
|
463
|
-
|
|
464
|
-
base_path = @provenance.base_path(file_key)
|
|
465
|
-
files[file_key] = %w[kept noop conflict kept_pruned].include?(status) && File.exist?(base_path) ? base_path : target_path
|
|
562
|
+
files[file_key] = next_base(file_key, @outcomes.fetch(file_key))
|
|
466
563
|
end
|
|
467
564
|
@existing.files.each_key do |old_key_str|
|
|
468
565
|
next if @bundle_md.include?(old_key_str)
|
|
469
566
|
next unless @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
|
|
470
567
|
|
|
471
|
-
|
|
472
|
-
src = File.exist?(base_path) ? base_path : File.join(@target_dir, old_key_str)
|
|
473
|
-
files[old_key_str] = src if File.exist?(src)
|
|
568
|
+
files[old_key_str] = recorded_base(@provenance.base_path(old_key_str))
|
|
474
569
|
end
|
|
475
570
|
files
|
|
476
571
|
end
|
|
477
572
|
|
|
573
|
+
# The file an Outcome's base names. nil for a kept file with no base:
|
|
574
|
+
# the user's file must not become one, or the next upgrade fast-forwards
|
|
575
|
+
# over it.
|
|
576
|
+
def next_base(file_key, outcome)
|
|
577
|
+
case outcome.base
|
|
578
|
+
when :recorded then recorded_base(@provenance.base_path(file_key))
|
|
579
|
+
when :on_disk then File.join(@target_dir, file_key)
|
|
580
|
+
when :incoming then File.join(@bundle_dir, file_key)
|
|
581
|
+
else raise ArgumentError, "unknown base #{outcome.base.inspect} for #{file_key}" # nil would drop the base silently
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# +base_path+, nil when there is no base (Provenance#write's "keep the
|
|
586
|
+
# entry, without one").
|
|
587
|
+
def recorded_base(base_path) = File.exist?(base_path) ? base_path : nil
|
|
588
|
+
|
|
478
589
|
# file key => its base's content source for a plain install. Skipped
|
|
479
590
|
# for a local edit: keep the installed base, so the next upgrade still
|
|
480
591
|
# sees the edit (not a fast-forward over it).
|
|
@@ -483,9 +594,8 @@ module Samagotchi
|
|
|
483
594
|
target_path = File.join(@target_dir, file_key)
|
|
484
595
|
next unless File.exist?(target_path)
|
|
485
596
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
map[file_key] = kept ? base_path : target_path
|
|
597
|
+
kept = @existing && @results.dig(file_key, :reason) == "already exists"
|
|
598
|
+
map[file_key] = kept ? recorded_base(@provenance.base_path(file_key)) : target_path
|
|
489
599
|
end
|
|
490
600
|
files.merge(dropped_owned_files)
|
|
491
601
|
end
|
|
@@ -503,8 +613,7 @@ module Samagotchi
|
|
|
503
613
|
next if @bundle_md.include?(file_key)
|
|
504
614
|
next unless File.exist?(File.join(@target_dir, file_key))
|
|
505
615
|
|
|
506
|
-
|
|
507
|
-
files[file_key] = base_path if File.exist?(base_path)
|
|
616
|
+
files[file_key] = recorded_base(@provenance.base_path(file_key))
|
|
508
617
|
end
|
|
509
618
|
end
|
|
510
619
|
|
|
@@ -520,7 +629,7 @@ module Samagotchi
|
|
|
520
629
|
return
|
|
521
630
|
else
|
|
522
631
|
@results[file_key] = { status: "skipped", reason: "already exists" }
|
|
523
|
-
@warnings << "Skipped #{file_key} (already exists;
|
|
632
|
+
@warnings << "Skipped #{file_key} (already exists; #{FileStanding::FORCE_TAKES})"
|
|
524
633
|
end
|
|
525
634
|
update_target_index(scope, target_path, file_key, owned: owned) unless @dry_run
|
|
526
635
|
end
|
|
@@ -548,7 +657,7 @@ module Samagotchi
|
|
|
548
657
|
remove_target_index(scope, key)
|
|
549
658
|
else
|
|
550
659
|
@results[key] = { status: "kept_pruned", reason: "local edits preserved" }
|
|
551
|
-
@warnings << "Kept #{key}: no longer in the bundle but edited locally (
|
|
660
|
+
@warnings << "Kept #{key}: no longer in the bundle but edited locally (#{FileStanding::FORCE_MOVES})"
|
|
552
661
|
end
|
|
553
662
|
end
|
|
554
663
|
end
|
|
@@ -15,8 +15,9 @@ module Samagotchi
|
|
|
15
15
|
# its own line, and they aren't listed again.
|
|
16
16
|
module Listing
|
|
17
17
|
# error is set (and the rest nil) when the provenance manifest.json
|
|
18
|
-
# can't be read; includes is a profile's recorded members, else nil
|
|
19
|
-
|
|
18
|
+
# can't be read; includes is a profile's recorded members, else nil;
|
|
19
|
+
# behind is its files that stay behind (InstalledBundle#behind_files).
|
|
20
|
+
Installed = Struct.new(:name, :version, :scope, :files, :installed_at, :upgrade, :error, :includes, :behind, keyword_init: true)
|
|
20
21
|
# source is the name `chi bundle install` takes (the shipped dir's
|
|
21
22
|
# name); includes is a profile's members ([] for a plain bundle).
|
|
22
23
|
Shipped = Struct.new(:name, :source, :version, :description, :includes, keyword_init: true)
|
|
@@ -50,7 +51,7 @@ module Samagotchi
|
|
|
50
51
|
Installed.new(name: name, version: bundle.version, scope: bundle.scope,
|
|
51
52
|
files: bundle.files.size, installed_at: bundle.installed_at,
|
|
52
53
|
upgrade: ship && newer?(ship.version, bundle.version) ? ship : nil,
|
|
53
|
-
includes: bundle.includes)
|
|
54
|
+
includes: bundle.includes, behind: bundle.behind_files)
|
|
54
55
|
end
|
|
55
56
|
end
|
|
56
57
|
|