samagotchi 0.149.0 → 0.150.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/README.md +4 -2
  4. data/docs/architecture.md +2 -2
  5. data/docs/cli.md +59 -24
  6. data/docs/configuration.md +5 -5
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/bridge.md +8 -0
  9. data/docs/internals/bundles.md +42 -14
  10. data/docs/internals/objectives.md +7 -2
  11. data/docs/internals/web-frontend.md +1 -1
  12. data/docs/internals/web-push.md +2 -2
  13. data/docs/internals/working-set.md +5 -2
  14. data/docs/memory.md +47 -9
  15. data/docs/plugins.md +7 -1
  16. data/docs/sessions.md +5 -4
  17. data/docs/testing.md +5 -0
  18. data/lib/samagotchi/atomic_file.rb +3 -0
  19. data/lib/samagotchi/bootstrap_command.rb +5 -2
  20. data/lib/samagotchi/bridge/card_store.rb +52 -7
  21. data/lib/samagotchi/bridge.rb +1 -1
  22. data/lib/samagotchi/bundle_command.rb +91 -68
  23. data/lib/samagotchi/bundler_env.rb +24 -0
  24. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  26. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -1
  27. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -1
  28. data/lib/samagotchi/bundles/known-names/manifest.yml +3 -2
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +2 -2
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  31. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  32. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  33. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  34. data/lib/samagotchi/chi_home.rb +4 -2
  35. data/lib/samagotchi/chi_shim.rb +2 -1
  36. data/lib/samagotchi/child_env.rb +10 -3
  37. data/lib/samagotchi/engine.rb +1 -1
  38. data/lib/samagotchi/git_env.rb +43 -0
  39. data/lib/samagotchi/guardrails/context.rb +2 -0
  40. data/lib/samagotchi/guardrails/targets.rb +4 -1
  41. data/lib/samagotchi/memory_bundle/asset_installer.rb +16 -2
  42. data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
  43. data/lib/samagotchi/memory_bundle/installed_bundle.rb +62 -1
  44. data/lib/samagotchi/memory_bundle/installer.rb +161 -52
  45. data/lib/samagotchi/memory_bundle/listing.rb +4 -3
  46. data/lib/samagotchi/memory_bundle/merger.rb +209 -8
  47. data/lib/samagotchi/memory_bundle/placeholder.rb +2 -1
  48. data/lib/samagotchi/memory_bundle/profile.rb +7 -3
  49. data/lib/samagotchi/memory_bundle/provenance.rb +178 -20
  50. data/lib/samagotchi/memory_bundle/remerge.rb +167 -0
  51. data/lib/samagotchi/memory_bundle/shipped_history.rb +79 -0
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +89 -24
  53. data/lib/samagotchi/memory_bundle/source.rb +3 -3
  54. data/lib/samagotchi/memory_bundle/status.rb +21 -3
  55. data/lib/samagotchi/memory_bundle/system_bundle.rb +98 -33
  56. data/lib/samagotchi/memory_bundle/text_merge.rb +399 -0
  57. data/lib/samagotchi/memory_bundle/trash.rb +58 -2
  58. data/lib/samagotchi/memory_bundle/uninstaller.rb +26 -6
  59. data/lib/samagotchi/objectives_check.rb +37 -7
  60. data/lib/samagotchi/project_env.rb +3 -2
  61. data/lib/samagotchi/self_report.rb +2 -0
  62. data/lib/samagotchi/testing/boundary.rb +1 -0
  63. data/lib/samagotchi/testing/minitest.rb +3 -0
  64. data/lib/samagotchi/testing/rspec.rb +3 -0
  65. data/lib/samagotchi/testing/sandbox.rb +3 -1
  66. data/lib/samagotchi/tools/delegate.rb +2 -1
  67. data/lib/samagotchi/tools/task_runtime.rb +13 -10
  68. data/lib/samagotchi/turn_flow.rb +3 -1
  69. data/lib/samagotchi/update_command.rb +76 -28
  70. data/lib/samagotchi/update_hint.rb +4 -1
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/auto_update.rb +8 -8
  73. data/lib/samagotchi/web/auto_update_record.rb +6 -4
  74. data/lib/samagotchi/web/participant_view.rb +1 -1
  75. data/lib/samagotchi/web/public/app.js +5 -3
  76. data/lib/samagotchi/web/public/block_open.js +13 -0
  77. data/lib/samagotchi/web/public/bubbles.js +4 -2
  78. data/lib/samagotchi/web/public/cards_place.js +10 -5
  79. data/lib/samagotchi/web/public/details_motion.js +2 -1
  80. data/lib/samagotchi/web/public/index.html +18 -14
  81. data/lib/samagotchi/web/public/stage_model.js +5 -6
  82. data/lib/samagotchi/web/public/stage_view.js +8 -7
  83. data/lib/samagotchi/web/public/turn_block.js +34 -19
  84. data/lib/samagotchi/web/public/turn_model.js +1 -1
  85. data/lib/samagotchi/web/public/update_sheet.js +8 -3
  86. data/lib/samagotchi/web/public/working_set_view.js +10 -7
  87. data/lib/samagotchi/web/push/notifier.rb +7 -9
  88. data/lib/samagotchi/web/session_hub.rb +2 -1
  89. data/lib/samagotchi/web/update_ops.rb +2 -1
  90. metadata +8 -1
@@ -1,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:, 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)
@@ -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 an upgrade moved the files the bundle no longer ships (nil:
67
- # none moved).
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
- install_memories
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
- kept = @results.select { |_, r| %w[kept kept_pruned].include?(r[:status].to_s) }.keys
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 << "Conflicts: #{conflicts.join(", ")}" unless conflicts.empty?
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.classify against the
342
- # base the last install recorded); a conflict leaves the local file
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
- base_path = @provenance.base_path(file_key)
346
- case Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
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
- @results[file_key] = { status: "conflict", reason: "local edits conflict with bundle update" }
359
- @conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path }
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
- base_path = @provenance.base_path(file_key)
375
- classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
376
- @results[file_key] = { status: classification.to_s }
377
- @conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path } if classification == :conflict
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 was merged isn't checked.
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
- conflicts: @conflicts.keys,
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
- # file key => the file whose content becomes its base. A file the
453
- # upgrade left as the user has it (kept, noop, conflict, kept_pruned)
454
- # keeps its old base, so the next upgrade still sees the local edit;
455
- # the rest (installed, updated, skipped) take what is on disk now. A
456
- # file the bundle dropped but the user kept stays recorded too.
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
- target_path = File.join(@target_dir, file_key)
461
- next unless File.exist?(target_path)
560
+ next unless File.exist?(File.join(@target_dir, file_key))
462
561
 
463
- status = @results[file_key] ? @results[file_key][:status].to_s : nil
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
- base_path = @provenance.base_path(old_key_str)
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
- base_path = @provenance.base_path(file_key)
487
- kept = @existing && @results.dig(file_key, :reason) == "already exists" && File.exist?(base_path)
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
- base_path = @provenance.base_path(file_key)
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; use --force to overwrite)"
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 (use --force to remove)"
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
- Installed = Struct.new(:name, :version, :scope, :files, :installed_at, :upgrade, :error, :includes, keyword_init: true)
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