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
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "index_updater"
4
+ require_relative "manifest"
5
+ require_relative "memory_files"
6
+ require_relative "merger"
7
+ require_relative "provenance"
8
+ require_relative "trash"
9
+ require_relative "../model_overlay"
10
+
11
+ module Samagotchi
12
+ module MemoryBundle
13
+ # The retry at the same version, memory files only: nothing else would come
14
+ # back to a file marked conflict until the bundle's next version. It merges
15
+ # what merges and settles those entries, leaving version, installed_at and
16
+ # the assets alone. Not the Installer: that rm_rfs guardrails, plugin and
17
+ # scripts, and GuardrailWiring#rules stats per tool call, so a live session
18
+ # could hit a transient deny.
19
+ #
20
+ # Each marked file, by what Merger::Upgrade says of it:
21
+ # - not shipped now: its mark, base and text stay, and it reads as behind
22
+ # unless level or missing, as every report has it;
23
+ # - the shipped text isn't the one the mark names (a checkout at the same
24
+ # VERSION can carry other text): left alone, behind as its mark says. A
25
+ # mark that names none (older chi) is tried too, the migration; a real
26
+ # conflict then records the digest and legacy_base, so
27
+ # Merger.merge_outcome keeps protecting its file (a merge there may not
28
+ # take a line out). A mark whose digest matches is tried on every run,
29
+ # since the user's file or git may have changed;
30
+ # - merged: replaced as the Installer does, base the incoming file;
31
+ # - noop, keep (a merge that adds nothing too): the file has nothing left
32
+ # to miss, so the mark goes;
33
+ # - conflict: behind, and the mark records why, only when that changed;
34
+ # - fast_forward (one Merger::Upgrade#outcome lets go ahead): the next
35
+ # upgrade's (save_marked_file), behind until then;
36
+ # - install: a missing file, which isn't behind (FileStanding.behind?): the
37
+ # next upgrade or a restore puts it back, and a warning says so;
38
+ # - one it can't read or replace: left as it is, behind, saying why.
39
+ #
40
+ # It can't loop: settling clears the mark, and a real conflict writes nothing.
41
+ # git runs once per marked file per run, and only for a marked file. A run
42
+ # is every chi start for the system bundle (SystemBundle.ensure!) and `chi
43
+ # update` for it and the other bundles chi ships; a bundle chi doesn't ship
44
+ # gets no Remerge, and `chi bundle upgrade` is its retry.
45
+ class Remerge
46
+ # merged: replaced (or, in a dry run, would be). settled: their mark gone
47
+ # with no merge. stamped: their mark was updated. behind: each file that
48
+ # stays behind, and why (FileStanding::Behind). work? says whether a real run
49
+ # would change anything.
50
+ Result = Data.define(:merged, :settled, :stamped, :behind, :warnings, :trash_copy) do
51
+ def work? = [merged, settled, stamped].any?(&:any?)
52
+ end
53
+ NOTHING = Result.new(merged: [], settled: [], stamped: [], behind: [], warnings: [], trash_copy: nil).freeze
54
+
55
+ # @param record [InstalledBundle, nil] the bundle's record (read when not given)
56
+ def initialize(name:, scope:, target_dir:, source_dir:, record: nil)
57
+ @name = name
58
+ @scope = scope
59
+ @target_dir = target_dir
60
+ @source_dir = source_dir
61
+ @provenance = Provenance.new(name: name)
62
+ @record = record || @provenance.record
63
+ end
64
+
65
+ # @param dry_run [Boolean] answer, writing nothing
66
+ # @return [Result]
67
+ def run(dry_run: false)
68
+ marked = @record ? @record.files.select { |_, entry| entry[:conflict] } : {}
69
+ return NOTHING if marked.empty?
70
+
71
+ @dry_run = dry_run
72
+ @merged = []
73
+ @settled = []
74
+ @stamped = []
75
+ @behind = []
76
+ @warnings = []
77
+ @trash = Trash.new(@name)
78
+ shipped = MemoryFiles.select(@source_dir, Manifest.read(dir: @source_dir))
79
+ marked.each { |key, entry| retry_file(key, entry, shipped) }
80
+ settle unless dry_run
81
+ Result.new(merged: @merged, settled: @settled, stamped: @stamped.map(&:file), behind: @behind, warnings: @warnings, trash_copy: @trash.copy_line)
82
+ end
83
+
84
+ private
85
+
86
+ def retry_file(key, entry, shipped)
87
+ behind = @record.behind_file?(key, view.paths(key)[:current])
88
+ unless shipped.include?(key)
89
+ not_shipped(key, entry) if behind
90
+ return
91
+ end
92
+
93
+ incoming_sha = Merger.digest(view.paths(key)[:incoming])
94
+ if entry[:incoming_sha] && entry[:incoming_sha] != incoming_sha
95
+ @behind << @record.behind_entry(key, entry) if behind
96
+ return
97
+ end
98
+
99
+ outcome = view.outcome(key)
100
+ case outcome.status
101
+ when :merged then merge_file(key, entry, outcome)
102
+ when :conflict then mark(key, entry, view.behind(key, outcome, @record.version))
103
+ when :noop, :keep then @settled << key
104
+ when :fast_forward then mark(key, entry, behind_for(key, FileStanding::WAITS_FOR_UPGRADE))
105
+ when :install then @warnings << "#{key}: #{FileStanding::FILE_MISSING}"
106
+ end
107
+ rescue SystemCallError => e
108
+ stay(key, FileStanding.left_as_it_is(e.message))
109
+ end
110
+
111
+ def behind_for(key, why) = FileStanding::Behind.new(file: key, version: @record.version, why: why)
112
+
113
+ # The record says it too, so chi bundle status reads as the row does. There
114
+ # is no bundle text to save or compare with: only the words can change.
115
+ def not_shipped(key, entry)
116
+ behind = behind_for(key, FileStanding::NOT_SHIPPED)
117
+ @behind << behind
118
+ @stamped << behind unless @record.behind_entry(key, entry) == behind
119
+ end
120
+
121
+ # Behind for now, with a reason worth no write to the record.
122
+ def stay(key, why) = @behind << behind_for(key, why)
123
+
124
+ # Behind, and the record says so: a write only when it has something new,
125
+ # so a real conflict at each chi start writes nothing.
126
+ def mark(key, entry, behind)
127
+ @behind << behind
128
+ @stamped << behind unless entry[:incoming_sha] && @record.behind_entry(key, entry) == behind && File.exist?(@provenance.incoming_path(key))
129
+ end
130
+
131
+ def merge_file(key, entry, outcome)
132
+ return @merged << key if @dry_run
133
+ return @merged << key if Merger.write_merged(view.paths(key)[:current], outcome, @trash)
134
+
135
+ mark(key, entry, behind_for(key, FileStanding.no_merge(TextMerge::RACED)))
136
+ end
137
+
138
+ def settle
139
+ files = @merged + @settled
140
+ @provenance.resolve_conflicts(files.to_h { |key| [key, view.paths(key)] }) unless files.empty?
141
+ @provenance.refresh_marks(@stamped, @source_dir) unless @stamped.empty?
142
+ @merged.each { |key| refresh_index(key) }
143
+ note_merges
144
+ end
145
+
146
+ def note_merges
147
+ @provenance.note_merged(@merged, version: @record.version, trash: @trash.dir) unless @merged.empty?
148
+ rescue StandardError => e
149
+ @warnings << "merge not noted on the record (#{e.message})"
150
+ end
151
+
152
+ # The file's size moved; its description and source stay. Not fatal, as
153
+ # in the Installer: the file is in place. An overlay has no line.
154
+ def refresh_index(key)
155
+ return if ModelOverlay.bundle_overlay?(key, bundle_files: @record.files.keys, target_dir: @target_dir)
156
+
157
+ IndexUpdater.update_index(@scope, key.delete_suffix(".md"), File.size(view.paths(key)[:current]), nil, source: @name)
158
+ rescue StandardError => e
159
+ @warnings << "index.md: line for #{key.delete_suffix(".md")} not updated (#{e.message})"
160
+ end
161
+
162
+ def view
163
+ @view ||= Merger::Upgrade.new(provenance: @provenance, target_dir: @target_dir, source_dir: @source_dir)
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "../chi_home"
5
+
6
+ module Samagotchi
7
+ module MemoryBundle
8
+ # Whether a file's bytes are what some installed samagotchi gem shipped for
9
+ # that file of that bundle. A mark from an older chi may have the user's own
10
+ # text as its base (Merger.merge_outcome); bytes a chi shipped can't be that.
11
+ #
12
+ # Asked only when the guard would otherwise fire, so an ordinary start pays
13
+ # nothing. Reads the gem dirs by hand: the running process's (Gem.path, which
14
+ # a chi home's boot string sets) and the chi home's (a checkout or a stray
15
+ # GEM_PATH runs on other gems than the home's), never loading a gem or asking
16
+ # Gem::Specification. Anything it can't read is no match, and no match keeps
17
+ # the guard: the fallback is the safe side.
18
+ module ShippedHistory
19
+ GEM_DIR = /\Asamagotchi-#{Gem::Version::VERSION_PATTERN}(?:-[a-z0-9_-]+)?\z/
20
+ BUNDLES = File.join("lib", "samagotchi", "bundles")
21
+
22
+ module_function
23
+
24
+ # @param bundle_name [String] the name in the manifest: a shipped dir may
25
+ # have another name in another version
26
+ # @param file_key [String] the file's name in the bundle
27
+ # @param path [String] the file holding the bytes asked about
28
+ # @return [Boolean]
29
+ def shipped?(bundle_name, file_key, path)
30
+ return false if file_key.include?("/")
31
+
32
+ bytes = File.binread(path)
33
+ gem_roots.any? { |root| holds?(root, bundle_name, file_key, bytes) }
34
+ rescue SystemCallError
35
+ false
36
+ end
37
+
38
+ # Each installed samagotchi gem's folder, wherever it is.
39
+ def gem_roots
40
+ gem_dirs.flat_map do |dir|
41
+ gems = File.join(dir, "gems")
42
+ Dir.children(gems).select { |name| name.match?(GEM_DIR) }.map { |name| File.join(gems, name) }
43
+ rescue SystemCallError
44
+ []
45
+ end
46
+ end
47
+
48
+ def gem_dirs = (Gem.path + home_gem_dirs).uniq
49
+
50
+ def home_gem_dirs
51
+ ChiHome.read&.gem_dirs || []
52
+ rescue StandardError
53
+ []
54
+ end
55
+
56
+ # The size first, the bytes next and the manifest last: 55 gems of 22
57
+ # bundles would be a thousand YAML files, and few candidates get that far.
58
+ def holds?(root, bundle_name, file_key, bytes)
59
+ bundles = File.join(root, BUNDLES)
60
+ Dir.glob("*", base: bundles).any? do |dir|
61
+ file = File.join(bundles, dir, file_key)
62
+ File.file?(file) && File.size(file) == bytes.bytesize && File.binread(file) == bytes &&
63
+ manifest_name(File.dirname(file)) == bundle_name
64
+ rescue SystemCallError
65
+ false
66
+ end
67
+ end
68
+
69
+ # The name a bundle's manifest gives, nil when it doesn't read. Not
70
+ # Manifest.read: an older manifest needn't pass this chi's checks.
71
+ def manifest_name(dir)
72
+ data = YAML.safe_load_file(File.join(dir, "manifest.yml"))
73
+ data.is_a?(Hash) ? data["name"] : nil
74
+ rescue StandardError
75
+ nil
76
+ end
77
+ end
78
+ end
79
+ end
@@ -6,6 +6,7 @@ require_relative "manifest"
6
6
  require_relative "merger"
7
7
  require_relative "profile"
8
8
  require_relative "provenance"
9
+ require_relative "remerge"
9
10
  require_relative "source"
10
11
  require_relative "status"
11
12
  require_relative "system_bundle"
@@ -22,8 +23,8 @@ module Samagotchi
22
23
  # newer and this chi meets its requires_chi. It never installs a
23
24
  # bundle that isn't installed and never downgrades.
24
25
  #
25
- # .md files take the Installer's 3-way merge: a conflict keeps the
26
- # user's file (kept). Hooks, rules and the plugin are replaced; an
26
+ # .md files take the Installer's 3-way merge: a clean text merge replaces
27
+ # the file (merged), a real conflict keeps the user's (behind). Hooks, rules and the plugin are replaced; an
27
28
  # edited one (which didn't load: its sha no longer matched) is listed
28
29
  # in replaced.
29
30
  #
@@ -32,15 +33,28 @@ module Samagotchi
32
33
  # to install (Profile.to_install, less the ones this chi can't load),
33
34
  # even at the same version, so a member that failed is retried. adds
34
35
  # names them.
36
+ #
37
+ # At the same version a row retries the files marked conflict (Remerge):
38
+ # :would_merge when that would change anything, then :merged when a file
39
+ # merged; what stays behind reads as behind whichever it is. Not retried for
40
+ # a project-scope bundle: chi bundle upgrade in the project.
35
41
  module ShippedUpdate
36
- # status: :would_update (plan), :updated, :up_to_date, :skipped,
37
- # :failed. note says why for skipped/failed. kept and replaced are
38
- # file names; under plan they are what apply would keep or replace.
42
+ # status: :would_update, :would_merge (plan), :updated, :merged,
43
+ # :up_to_date, :skipped, :failed. note says why for skipped/failed, and
44
+ # what a retry at the same version warned of. merged and replaced are file
45
+ # names, behind FileStanding::Behind; under plan they are what apply would
46
+ # merge, replace or leave behind.
39
47
  # adds is nil but for a profile: the members it installs.
40
48
  # kept_own is nil but for a profile that installed a member: its
41
49
  # Profile.kept_own_line lines.
42
- Row = Struct.new(:name, :from, :to, :status, :note, :kept, :replaced, :source_dir, :scope, :adds, :kept_own,
43
- keyword_init: true)
50
+ # trash_copy is nil but under apply when it saved edited files
51
+ # (Installer#trash_copy_line).
52
+ # earlier: the merge notes on its record when the plan read it
53
+ # (FileStanding::MergeNote): this run's own merges aren't among them.
54
+ Row = Struct.new(:name, :from, :to, :status, :note, :merged, :behind, :replaced, :source_dir, :scope, :adds, :kept_own,
55
+ :trash_copy, :earlier, keyword_init: true) do
56
+ def kept = Array(behind).map(&:file)
57
+ end
44
58
 
45
59
  module_function
46
60
 
@@ -49,7 +63,11 @@ module Samagotchi
49
63
  def plan(shipped_dir: SourceNormalizer::SHIPPED_DIR, chi_version: Samagotchi::VERSION)
50
64
  shipped = Listing.shipped(dir: shipped_dir).to_h { |s| [s.name, s] }
51
65
  installed.map do |name, bundle|
52
- plan_row(name, bundle, shipped[name], shipped_dir, chi_version)
66
+ row = plan_row(name, bundle, shipped[name], shipped_dir, chi_version)
67
+ # A row that retried nothing still has its files that stay behind.
68
+ row.behind = bundle.behind_files if row.status == :skipped && !bundle.error?
69
+ row.earlier = bundle.merge_notes
70
+ row
53
71
  end
54
72
  end
55
73
 
@@ -69,11 +87,13 @@ module Samagotchi
69
87
  installed.filter_map { |name, bundle| bundle unless bundle.error? || bundle.profile? || from_chi?(bundle, shipped[name]) }
70
88
  end
71
89
 
72
- # Runs the Installer for each :would_update row; the others pass
73
- # through. A failing bundle doesn't stop the rest.
90
+ # Runs the Installer for each :would_update row, Remerge for each
91
+ # :would_merge row; the others pass through. A failing bundle doesn't
92
+ # stop the rest.
74
93
  # @return [Array<Row>]
75
94
  def apply(rows)
76
95
  rows.map do |row|
96
+ next apply_remerge(row) if row.status == :would_merge
77
97
  next row unless row.status == :would_update
78
98
  next apply_profile(row) if row.adds
79
99
 
@@ -82,16 +102,32 @@ module Samagotchi
82
102
  installer.run
83
103
  row.dup.tap do |r|
84
104
  r.status = :updated
85
- r.kept = installer.conflicts.keys
105
+ r.merged = installer.merged_files
106
+ r.behind = installer.behind
107
+ r.trash_copy = installer.trash_copy_line
86
108
  end
87
109
  rescue StandardError => e
88
110
  row.dup.tap do |r|
89
111
  r.status = :failed
90
112
  r.note = e.message.lines.first.to_s.strip
113
+ r.merged = installer&.merged_files || []
114
+ r.behind = installer&.behind || []
115
+ r.trash_copy = installer&.trash_copy_line
91
116
  end
92
117
  end
93
118
  end
94
119
 
120
+ def apply_remerge(row)
121
+ done = remerge(row).run
122
+ row.dup.tap do |r|
123
+ r.status = done.merged.empty? ? :up_to_date : :merged
124
+ r.merged = done.merged
125
+ r.behind = done.behind
126
+ r.trash_copy = done.trash_copy
127
+ r.note = note_of(done)
128
+ end
129
+ end
130
+
95
131
  def apply_profile(row)
96
132
  result = Profile.install(row.source_dir, shipped_dir: File.dirname(row.source_dir))
97
133
  row.dup.tap do |r|
@@ -111,7 +147,7 @@ module Samagotchi
111
147
  def from_chi?(bundle, ship) = !ship.nil? && bundle.source.to_s.match?(SourceNormalizer::SHIPPED_SOURCE)
112
148
 
113
149
  def plan_row(name, bundle, ship, shipped_dir, chi_version)
114
- row = Row.new(name: name, kept: [], replaced: [])
150
+ row = Row.new(name: name, merged: [], behind: [], replaced: [])
115
151
  return skip(row, "manifest.json unreadable") if bundle.error?
116
152
 
117
153
  row.from = bundle.version.to_s
@@ -123,7 +159,7 @@ module Samagotchi
123
159
 
124
160
  row.source_dir = File.join(shipped_dir, ship.source)
125
161
  return profile_row(row, bundle, shipped_dir, chi_version) if Profile.shipped_meta?(row.source_dir, shipped_dir: shipped_dir)
126
- return row.tap { |r| r.status = :up_to_date; r.to = nil } unless Listing.newer?(row.to, row.from)
162
+ return same_version_row(row, bundle) unless Listing.newer?(row.to, row.from)
127
163
 
128
164
  manifest = Manifest.read(dir: row.source_dir)
129
165
  if Manifest.requires_chi_failure(manifest.requires_chi, chi_version)
@@ -132,12 +168,43 @@ module Samagotchi
132
168
  return skip(row, "project scope: chi bundle upgrade #{ship.source} in the project") if row.scope == "project"
133
169
  return skip(row, "unknown scope #{row.scope}: upgrade chi or reinstall") unless MemoryPaths.scope_dir(row.scope)
134
170
 
135
- row.kept = conflicts(row, bundle)
171
+ forecast = forecast(row, bundle)
172
+ row.merged = forecast.merged
173
+ row.behind = forecast.behind
136
174
  row.replaced = edited_executables(name, bundle)
137
175
  row.status = :would_update
138
176
  row
177
+ rescue SystemCallError => e
178
+ # A file it can't read: the upgrade would fail on it, so this is that
179
+ # row, not a crash of the whole plan. Apply passes it through.
180
+ row.status = :failed
181
+ row.note = Trash.failure_message(e)
182
+ row
183
+ end
184
+
185
+ # Up to date, unless a file marked conflict could settle now or stays behind.
186
+ def same_version_row(row, bundle)
187
+ row.to = nil
188
+ row.status = :up_to_date
189
+ if row.scope == "project" || !MemoryPaths.scope_dir(row.scope)
190
+ row.behind = bundle.behind_files
191
+ return row
192
+ end
193
+
194
+ planned = remerge(row).run(dry_run: true)
195
+ row.merged = planned.merged
196
+ row.behind = planned.behind
197
+ row.note = note_of(planned)
198
+ row.status = :would_merge if planned.work?
199
+ row
139
200
  end
140
201
 
202
+ def remerge(row)
203
+ Remerge.new(name: row.name, scope: row.scope, target_dir: MemoryPaths.scope_dir!(row.scope), source_dir: row.source_dir)
204
+ end
205
+
206
+ def note_of(remerged) = remerged.warnings.empty? ? nil : remerged.warnings.join("; ")
207
+
141
208
  def profile_row(row, bundle, shipped_dir, chi_version)
142
209
  manifest = Manifest.read(dir: row.source_dir)
143
210
  newer = Listing.newer?(row.to, row.from)
@@ -162,16 +229,14 @@ module Samagotchi
162
229
  row
163
230
  end
164
231
 
165
- # The bundle's .md files whose local edits conflict with the shipped
166
- # version (a same-name file it didn't install is skipped, not merged).
167
- def conflicts(row, bundle)
168
- provenance = Provenance.new(name: row.name)
169
- target_dir = MemoryPaths.scope_dir!(row.scope)
170
- owned = bundle.files.keys
171
- (Dir.glob(File.join(row.source_dir, "*.md")).map { |f| File.basename(f) }.sort & owned).select do |key|
172
- Merger.classify(base_path: provenance.base_path(key), current_path: File.join(target_dir, key),
173
- incoming_path: File.join(row.source_dir, key)) == :conflict
174
- end
232
+ # What the upgrade would do to the bundle's .md files that local edits and
233
+ # the shipped version both changed (a same-name file it didn't install is
234
+ # skipped, not merged). Every shipped *.md, not MemoryFiles.select: see
235
+ # Merger::Upgrade#forecast.
236
+ def forecast(row, bundle)
237
+ shipped = Dir.glob(File.join(row.source_dir, "*.md")).map { |f| File.basename(f) }.sort
238
+ Merger::Upgrade.new(provenance: Provenance.new(name: row.name), target_dir: MemoryPaths.scope_dir!(row.scope),
239
+ source_dir: row.source_dir).forecast(shipped & bundle.files.keys, version: row.to)
175
240
  end
176
241
 
177
242
  # Installed hooks, rules and the plugin whose sha differs from the
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "tmpdir"
4
4
  require "fileutils"
5
- require "shellwords"
5
+ require_relative "../git_env"
6
6
 
7
7
  module Samagotchi
8
8
  module MemoryBundle
@@ -81,13 +81,13 @@ module Samagotchi
81
81
  args = ["git", "clone", "--depth", "1"]
82
82
  args += ["--branch", ref] if ref && !ref.empty?
83
83
  args += [url, clone_dir]
84
- result = system(*args)
84
+ result = system(GitEnv::UNSET, *args)
85
85
  raise UnknownSourceError, "git clone failed for #{git_url}" unless result && File.directory?(clone_dir)
86
86
 
87
87
  # Capture HEAD commit before stripping .git (audit trail)
88
88
  commit = nil
89
89
  begin
90
- commit = `git -C #{clone_dir.shellescape} rev-parse HEAD 2>/dev/null`.strip
90
+ commit = IO.popen(GitEnv::UNSET, ["git", "-C", clone_dir, "rev-parse", "HEAD"], err: File::NULL, &:read).strip
91
91
  commit = nil if commit.empty?
92
92
  rescue StandardError
93
93
  commit = nil
@@ -18,7 +18,9 @@ module Samagotchi
18
18
  # disk, its installed plugin, why none of its hooks load, and its needs
19
19
  # with what this process's PATH found. The +files+ entries and +plugin+
20
20
  # stay Hashes: their fields differ per case (an unchecked file has no
21
- # paths at all).
21
+ # paths at all). An entry's behind is its FileStanding::Behind, nil for a
22
+ # file that isn't; merge_note its FileStanding::MergeNote, nil without one;
23
+ # unreadable: the file is there, but this chi can't read it.
22
24
  Report = Data.define(:provenance, :scope, :scope_error, :target_dir, :files, :plugin,
23
25
  :hooks_requires_failure, :needs)
24
26
 
@@ -34,14 +36,19 @@ module Samagotchi
34
36
  target_dir = MemoryPaths.scope_dir(scope)
35
37
  files = bundle.files
36
38
  details = {}
39
+ behind = bundle.behind_files(target_dir).to_h { |file| [file.file, file] }
40
+ notes = bundle.merge_notes.to_h { |note| [note.file, note] }
37
41
  files.each do |file_key_str, meta|
38
42
  next details[file_key_str] = unchecked_file(meta) unless target_dir
39
43
 
40
44
  target_path = File.join(target_dir, file_key_str)
41
45
  base_path = provenance.base_path(file_key_str)
42
46
  stored_checksum = meta[:checksum]
43
- current_checksum = File.exist?(target_path) ? Digest::SHA256.hexdigest(File.read(target_path)) : nil
44
- base_checksum = File.exist?(base_path) ? Digest::SHA256.hexdigest(File.read(base_path)) : nil
47
+ current_checksum = checksum(target_path)
48
+ unreadable = current_checksum == :unreadable
49
+ current_checksum = nil if unreadable
50
+ base_checksum = checksum(base_path)
51
+ base_checksum = nil if base_checksum == :unreadable
45
52
  modified = base_checksum && current_checksum && base_checksum != current_checksum
46
53
  missing = !File.exist?(target_path)
47
54
  # A model overlay has no index line (Installer#note_overlay).
@@ -49,6 +56,9 @@ module Samagotchi
49
56
  index_present = overlay ? nil : index_has_entry?(scope, file_key_str)
50
57
  details[file_key_str] = {
51
58
  conflict: meta[:conflict] == true,
59
+ behind: behind[file_key_str],
60
+ merge_note: notes[file_key_str],
61
+ unreadable: unreadable,
52
62
  stored_checksum: stored_checksum,
53
63
  current_checksum: current_checksum,
54
64
  base_checksum: base_checksum,
@@ -65,6 +75,14 @@ module Samagotchi
65
75
  hooks_requires_failure: hooks_requires_failure(bundle), needs: needs_status(bundle))
66
76
  end
67
77
 
78
+ # The file's digest, nil when there is none, :unreadable when it can't be read.
79
+ def self.checksum(path)
80
+ File.exist?(path) ? Digest::SHA256.file(path).hexdigest : nil
81
+ rescue SystemCallError
82
+ :unreadable
83
+ end
84
+ private_class_method :checksum
85
+
68
86
  # Why none of the bundle's hooks load (this chi doesn't meet its
69
87
  # requires_chi, Hooks::BundleLoader), or nil: it has no hooks or meets it.
70
88
  def self.hooks_requires_failure(bundle)