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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/README.md +4 -2
  4. data/docs/architecture.md +3 -3
  5. data/docs/bundles.md +38 -1
  6. data/docs/cli.md +76 -24
  7. data/docs/configuration.md +8 -6
  8. data/docs/guardrails.md +2 -1
  9. data/docs/hooks.md +2 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/bridge.md +8 -0
  12. data/docs/internals/bundles.md +42 -14
  13. data/docs/internals/objectives.md +269 -0
  14. data/docs/internals/web-frontend.md +4 -1
  15. data/docs/internals/web-push.md +2 -2
  16. data/docs/internals/working-set.md +5 -2
  17. data/docs/memory.md +47 -9
  18. data/docs/plugins.md +10 -2
  19. data/docs/releasing.md +1 -1
  20. data/docs/sessions.md +45 -2
  21. data/docs/system-one.md +17 -1
  22. data/docs/testing.md +5 -0
  23. data/lib/samagotchi/atomic_file.rb +3 -0
  24. data/lib/samagotchi/bootstrap_command.rb +5 -2
  25. data/lib/samagotchi/bridge/card_store.rb +52 -7
  26. data/lib/samagotchi/bridge.rb +1 -1
  27. data/lib/samagotchi/bundle_command.rb +91 -68
  28. data/lib/samagotchi/bundler_env.rb +24 -0
  29. data/lib/samagotchi/bundles/coordinator/manifest.yml +6 -4
  30. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  31. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +9 -0
  32. data/lib/samagotchi/bundles/decision-card/manifest.yml +13 -0
  33. data/lib/samagotchi/bundles/decision-card/plugin.rb +280 -0
  34. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  35. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  36. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -1
  37. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -1
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +3 -2
  39. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +2 -2
  40. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  41. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  42. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  43. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  44. data/lib/samagotchi/chi_home.rb +4 -2
  45. data/lib/samagotchi/chi_shim.rb +2 -1
  46. data/lib/samagotchi/child_env.rb +10 -3
  47. data/lib/samagotchi/config.rb +7 -1
  48. data/lib/samagotchi/context_note.rb +17 -5
  49. data/lib/samagotchi/engine.rb +113 -6
  50. data/lib/samagotchi/engine_peers.rb +11 -0
  51. data/lib/samagotchi/git_env.rb +43 -0
  52. data/lib/samagotchi/guardrails/context.rb +2 -0
  53. data/lib/samagotchi/guardrails/pair_boundary.rb +3 -2
  54. data/lib/samagotchi/guardrails/targets.rb +4 -1
  55. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  56. data/lib/samagotchi/memory_bundle/asset_installer.rb +16 -2
  57. data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
  58. data/lib/samagotchi/memory_bundle/installed_bundle.rb +62 -1
  59. data/lib/samagotchi/memory_bundle/installer.rb +161 -52
  60. data/lib/samagotchi/memory_bundle/listing.rb +4 -3
  61. data/lib/samagotchi/memory_bundle/merger.rb +209 -8
  62. data/lib/samagotchi/memory_bundle/placeholder.rb +2 -1
  63. data/lib/samagotchi/memory_bundle/profile.rb +7 -3
  64. data/lib/samagotchi/memory_bundle/provenance.rb +178 -20
  65. data/lib/samagotchi/memory_bundle/remerge.rb +167 -0
  66. data/lib/samagotchi/memory_bundle/shipped_history.rb +79 -0
  67. data/lib/samagotchi/memory_bundle/shipped_update.rb +89 -24
  68. data/lib/samagotchi/memory_bundle/source.rb +3 -3
  69. data/lib/samagotchi/memory_bundle/status.rb +21 -3
  70. data/lib/samagotchi/memory_bundle/system_bundle.rb +98 -33
  71. data/lib/samagotchi/memory_bundle/text_merge.rb +399 -0
  72. data/lib/samagotchi/memory_bundle/trash.rb +58 -2
  73. data/lib/samagotchi/memory_bundle/uninstaller.rb +26 -6
  74. data/lib/samagotchi/note_command.rb +1 -1
  75. data/lib/samagotchi/note_delivery.rb +6 -2
  76. data/lib/samagotchi/objectives.rb +395 -0
  77. data/lib/samagotchi/objectives_audit.rb +115 -0
  78. data/lib/samagotchi/objectives_check.rb +365 -0
  79. data/lib/samagotchi/objectives_command.rb +192 -0
  80. data/lib/samagotchi/plugin/api.rb +2 -1
  81. data/lib/samagotchi/plugin/context.rb +21 -1
  82. data/lib/samagotchi/project_env.rb +3 -2
  83. data/lib/samagotchi/self_report.rb +2 -0
  84. data/lib/samagotchi/session_commands.rb +15 -0
  85. data/lib/samagotchi/session_continue.rb +3 -0
  86. data/lib/samagotchi/session_inbox.rb +14 -6
  87. data/lib/samagotchi/session_manager.rb +1 -1
  88. data/lib/samagotchi/steer.rb +24 -4
  89. data/lib/samagotchi/testing/boundary.rb +2 -0
  90. data/lib/samagotchi/testing/host.rb +1 -0
  91. data/lib/samagotchi/testing/minitest.rb +3 -0
  92. data/lib/samagotchi/testing/rspec.rb +3 -0
  93. data/lib/samagotchi/testing/sandbox.rb +3 -1
  94. data/lib/samagotchi/tool_activity.rb +17 -0
  95. data/lib/samagotchi/tool_declarations.rb +34 -0
  96. data/lib/samagotchi/tools/builtin_calls.rb +2 -1
  97. data/lib/samagotchi/tools/builtins.rb +4 -1
  98. data/lib/samagotchi/tools/delegate.rb +2 -1
  99. data/lib/samagotchi/tools/objectives_tool.rb +75 -0
  100. data/lib/samagotchi/tools/peers.rb +3 -0
  101. data/lib/samagotchi/tools/task_runtime.rb +13 -10
  102. data/lib/samagotchi/turn_flow.rb +3 -1
  103. data/lib/samagotchi/update_command.rb +76 -28
  104. data/lib/samagotchi/update_hint.rb +4 -1
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/web/auto_update.rb +8 -8
  107. data/lib/samagotchi/web/auto_update_record.rb +6 -4
  108. data/lib/samagotchi/web/message_display.rb +9 -0
  109. data/lib/samagotchi/web/participant_view.rb +2 -2
  110. data/lib/samagotchi/web/public/app.js +15 -3
  111. data/lib/samagotchi/web/public/block_open.js +13 -0
  112. data/lib/samagotchi/web/public/bubbles.js +4 -2
  113. data/lib/samagotchi/web/public/cards_place.js +30 -9
  114. data/lib/samagotchi/web/public/commands.js +4 -1
  115. data/lib/samagotchi/web/public/details_motion.js +2 -1
  116. data/lib/samagotchi/web/public/index.html +20 -14
  117. data/lib/samagotchi/web/public/objectives.css +80 -0
  118. data/lib/samagotchi/web/public/objectives_entry.js +22 -0
  119. data/lib/samagotchi/web/public/objectives_model.js +100 -0
  120. data/lib/samagotchi/web/public/objectives_moves.js +145 -0
  121. data/lib/samagotchi/web/public/objectives_strip.js +242 -0
  122. data/lib/samagotchi/web/public/objectives_wiring.js +62 -0
  123. data/lib/samagotchi/web/public/stage_model.js +5 -6
  124. data/lib/samagotchi/web/public/stage_view.js +8 -7
  125. data/lib/samagotchi/web/public/stream_handlers.js +6 -1
  126. data/lib/samagotchi/web/public/turn_block.js +34 -19
  127. data/lib/samagotchi/web/public/turn_html.js +4 -1
  128. data/lib/samagotchi/web/public/turn_model.js +1 -1
  129. data/lib/samagotchi/web/public/update_sheet.js +8 -3
  130. data/lib/samagotchi/web/public/working_set_view.js +10 -7
  131. data/lib/samagotchi/web/push/notifier.rb +7 -9
  132. data/lib/samagotchi/web/session_hub.rb +2 -1
  133. data/lib/samagotchi/web/session_show.rb +12 -1
  134. data/lib/samagotchi/web/update_ops.rb +2 -1
  135. data/lib/samagotchi/worker.rb +3 -0
  136. data/lib/samagotchi/worker_inbound.rb +16 -8
  137. metadata +22 -1
@@ -4,7 +4,11 @@ require "fileutils"
4
4
  require_relative "installer"
5
5
  require_relative "provenance"
6
6
  require_relative "manifest"
7
+ require_relative "index_updater"
8
+ require_relative "memory_files"
7
9
  require_relative "merger"
10
+ require_relative "remerge"
11
+ require_relative "trash"
8
12
  require_relative "../log"
9
13
 
10
14
  module Samagotchi
@@ -16,8 +20,9 @@ module Samagotchi
16
20
  # (~/.config/samagotchi/memories) on first Engine creation.
17
21
  #
18
22
  # A newer shipped version (manifest.yml) triggers a 3-way upgrade:
19
- # fast-forward when not edited, keep when bundle unchanged, conflict keeps
20
- # local edit with a warning. An older shipped version is left alone, so an
23
+ # fast-forward when not edited, keep when bundle unchanged, a clean text
24
+ # merge replaces the file, a real conflict keeps local edit with a warning.
25
+ # An older shipped version is left alone, so an
21
26
  # old checkout never downgrades the installed bundle; it says so once.
22
27
  module SystemBundle
23
28
  BUNDLE_NAME = "samagotchi-system"
@@ -29,12 +34,17 @@ module Samagotchi
29
34
  module_function
30
35
 
31
36
  # What sync did (or, with dry_run, would do). status is :installed,
32
- # :updated, :restored (missing files put back), :up_to_date,
37
+ # :updated, :restored (missing files put back), :merged (a file marked
38
+ # conflict merged at the same version, Remerge), :up_to_date,
33
39
  # :newer_installed (left as is), :skipped (SKIP_ENV or no shipped
34
- # bundle) or :failed (error: says why); kept lists the files whose
35
- # local edits conflicted with the update and were kept.
36
- Result = Struct.new(:from, :to, :status, :kept, :warnings, :error, keyword_init: true) do
37
- def changed? = %i[installed updated restored].include?(status)
40
+ # bundle) or :failed (error: says why); merged lists the files a text
41
+ # merge replaced (trash_copy says where the old ones went), behind each file
42
+ # that stays behind (FileStanding::Behind). ensure! says only what an upgrade
43
+ # left behind: a file that was already behind isn't news at every start.
44
+ Result = Struct.new(:from, :to, :status, :merged, :behind, :trash_copy, :warnings, :error, keyword_init: true) do
45
+ def changed? = %i[installed updated restored merged].include?(status)
46
+
47
+ def kept = Array(behind).map(&:file)
38
48
  end
39
49
 
40
50
  # Ensure the system bundle is installed and up-to-date.
@@ -48,13 +58,35 @@ module Samagotchi
48
58
  when :newer_installed
49
59
  with_lock { note_newer_installed(result.from, result.to) }
50
60
  end
51
- result.kept.each do |file_key|
52
- Log.info(:memory, "system_local_edit_kept", echo: "[samagotchi-system] kept local edit in #{file_key} (bundle v#{result.to} has update — run: chi bundle status #{BUNDLE_NAME} / chi bundle diff #{BUNDLE_NAME} #{file_key})", file: file_key.to_s)
61
+ unless result.merged.empty?
62
+ note = [FileStanding.merged_line(result.merged, result.to), result.trash_copy].compact.join("; ")
63
+ Log.info(:memory, "system_bundle_merged", echo: "[samagotchi-system] #{note}", files: result.merged.join(","), to: result.to.to_s)
64
+ end
65
+ if result.status == :updated
66
+ result.behind.each do |file|
67
+ Log.info(:memory, "system_file_behind", echo: "[samagotchi-system] #{file.line} (#{FileStanding.diff_hint(BUNDLE_NAME, file.file)})", file: file.file)
68
+ end
53
69
  end
54
70
  result.warnings.each { |w| Log.warn(:memory, "system_bundle_warning", echo: "[samagotchi-system] #{w}") }
55
71
  result.changed?
56
72
  end
57
73
 
74
+ # The merges no chi update has shown (MergeNote): ask before .sync, whose
75
+ # own merges its caller reports itself.
76
+ # @return [Array<FileStanding::MergeNote>]
77
+ def merge_notes
78
+ Provenance.new(name: BUNDLE_NAME).record&.merge_notes || []
79
+ rescue StandardError
80
+ []
81
+ end
82
+
83
+ # Those notes have been shown (Provenance#clear_merge_notes), under the lock.
84
+ def clear_merge_notes(notes, files: [])
85
+ with_lock { Provenance.new(name: BUNDLE_NAME).clear_merge_notes(notes, files: files) }
86
+ rescue StandardError
87
+ nil
88
+ end
89
+
58
90
  # Install, upgrade or repair the installed system bundle to match the
59
91
  # shipped one, under the lock. Never raises; logs nothing (ensure!
60
92
  # and chi update report the Result).
@@ -80,20 +112,29 @@ module Samagotchi
80
112
  elsif installed_newer?(from, to)
81
113
  # Running an older checkout/gem: never downgrade the user's system memories
82
114
  # (not even to restore a missing file). `chi self` shows installed vs shipped.
83
- result(:newer_installed, from: from, to: to)
115
+ result(:newer_installed, from: from, to: to, behind: bundle.behind_files)
84
116
  elsif from != to
85
- dry_run ? result(:updated, from: from, to: to, kept: conflicting(gem_manifest)) : upgrade_existing(from, to)
117
+ dry_run ? planned_update(gem_manifest, from, to) : upgrade_existing(from, to)
86
118
  else
87
119
  # Already at desired version — still verify files present (e.g. user deleted one)
88
- verify_files_present(gem_manifest, from, dry_run)
120
+ # and retry the files marked conflict.
121
+ verified = verify_files_present(gem_manifest, from, dry_run)
122
+ next remerge(bundle, verified, dry_run) if verified.status == :up_to_date
123
+
124
+ verified.tap { |done| done.behind = Provenance.new(name: BUNDLE_NAME).record.behind_files }
89
125
  end
90
126
  end
127
+ rescue Installer::InstallError => e
128
+ result(:failed, error: e.message)
129
+ rescue SystemCallError => e
130
+ result(:failed, error: Trash.failure_message(e))
91
131
  rescue StandardError => e
92
132
  result(:failed, error: "ensure failed: #{e.class}: #{e.message}")
93
133
  end
94
134
 
95
- def result(status, from: nil, to: nil, kept: [], warnings: [], error: nil)
96
- Result.new(from: from, to: to, status: status, kept: kept, warnings: warnings, error: error)
135
+ def result(status, from: nil, to: nil, merged: [], behind: [], trash_copy: nil, warnings: [], error: nil)
136
+ Result.new(from: from, to: to, status: status, merged: merged, behind: behind, trash_copy: trash_copy,
137
+ warnings: warnings, error: error)
97
138
  end
98
139
 
99
140
  # Parallel chi starts on one config dir take turns: the second one reads
@@ -149,45 +190,69 @@ module Samagotchi
149
190
  end
150
191
 
151
192
  # Installer handles fast_forward/keep/noop; a conflict keeps the local
152
- # file, and its own "Conflict in" warning is left out (kept says it).
193
+ # file, and its own warning is left out (behind says it).
153
194
  def upgrade_existing(from, to)
154
195
  installer = upgrade_installer
155
196
  installer.run
156
- kept = installer.conflicts.keys
157
- warnings = installer.warnings.reject { |w| kept.any? { |k| w.start_with?("Conflict in #{k}:") } }
158
- result(:updated, from: from, to: to, kept: kept, warnings: warnings)
197
+ behind = installer.behind
198
+ warnings = installer.warnings.reject { |w| behind.any? { |file| w.start_with?(file.line) } }
199
+ result(:updated, from: from, to: to, merged: installer.merged_files, behind: behind, trash_copy: installer.trash_copy_line, warnings: warnings)
159
200
  end
160
201
 
161
202
  # A file from the bundle missing on disk (the user deleted it) is put
162
- # back via the Installer (which classifies it as :install), without a
163
- # full reinstall.
203
+ # back, and nothing else: at the same version a checkout can carry other
204
+ # text, so no other file is merged here (Remerge has its guard), and a live
205
+ # session's guardrails, plugin and scripts stay where they are.
164
206
  def verify_files_present(gem_manifest, version, dry_run)
165
207
  target_dir = MemoryPaths.system_dir
166
- missing = gem_manifest.files.keys.any? { |k| !File.exist?(File.join(target_dir, k)) }
167
- return result(:up_to_date, from: version, to: version) unless missing
208
+ missing = MemoryFiles.select(GEM_BUNDLE_DIR, gem_manifest).reject { |key| File.exist?(File.join(target_dir, key)) }
209
+ return result(:up_to_date, from: version, to: version) if missing.empty?
168
210
  return result(:restored, from: version, to: version) if dry_run
169
211
 
170
- upgrade_installer.run
171
- result(:restored, from: version, to: version)
212
+ warnings = missing.filter_map { |key| restore_file(key, gem_manifest.file_descriptions[key]) }
213
+ result(:restored, from: version, to: version, warnings: warnings)
172
214
  end
173
215
 
174
- # The files an upgrade would keep: installed by the bundle, edited
175
- # here and changed in the shipped bundle (a dry run's kept).
176
- def conflicting(gem_manifest)
216
+ # @return [String, nil] a warning: the file is in place, its index line isn't
217
+ def restore_file(key, description)
218
+ source = File.join(GEM_BUNDLE_DIR, key)
219
+ target = File.join(MemoryPaths.system_dir, key)
220
+ FileUtils.cp(source, target)
221
+ Provenance.new(name: BUNDLE_NAME).record_restored(key, source, description: description)
222
+ begin
223
+ IndexUpdater.update_index(SCOPE, key.delete_suffix(".md"), File.size(target), description, source: BUNDLE_NAME)
224
+ nil
225
+ rescue StandardError => e
226
+ "index.md: line for #{key.delete_suffix(".md")} not updated (#{e.message})"
227
+ end
228
+ end
229
+
230
+ # An up-to-date bundle's marked files, retried. No marks: nothing is read.
231
+ def remerge(bundle, verified, dry_run)
232
+ done = Remerge.new(name: BUNDLE_NAME, scope: SCOPE, target_dir: MemoryPaths.system_dir, source_dir: GEM_BUNDLE_DIR, record: bundle)
233
+ .run(dry_run: dry_run)
234
+ status = done.merged.empty? ? :up_to_date : :merged
235
+ result(status, from: verified.from, to: verified.to, merged: done.merged, behind: done.behind, trash_copy: done.trash_copy, warnings: done.warnings)
236
+ end
237
+
238
+ # A dry run's answer for an upgrade: what it would merge and keep, among
239
+ # the files installed by the bundle that are edited here and changed in
240
+ # the shipped bundle. The manifest's files:, not MemoryFiles.select: see
241
+ # Merger::Upgrade#forecast.
242
+ def planned_update(gem_manifest, from, to)
177
243
  provenance = Provenance.new(name: BUNDLE_NAME)
178
244
  owned = provenance.record&.files&.keys || []
179
- (gem_manifest.files.keys.map(&:to_s).sort & owned).select do |key|
180
- Merger.classify(base_path: provenance.base_path(key), current_path: File.join(MemoryPaths.system_dir, key),
181
- incoming_path: File.join(GEM_BUNDLE_DIR, key)) == :conflict
182
- end
245
+ forecast = Merger::Upgrade.new(provenance: provenance, target_dir: MemoryPaths.system_dir, source_dir: GEM_BUNDLE_DIR)
246
+ .forecast(gem_manifest.files.keys.map(&:to_s).sort & owned, version: to)
247
+ result(:updated, from: from, to: to, merged: forecast.merged, behind: forecast.behind)
183
248
  end
184
249
 
185
250
  def upgrade_installer
186
251
  Installer.new(source: GEM_BUNDLE_DIR, name: BUNDLE_NAME, scope: SCOPE, force: false, strict: true, upgrade: true,
187
252
  shipped_dir: File.dirname(GEM_BUNDLE_DIR))
188
253
  end
189
- private_class_method :with_lock, :note_newer_installed, :install_fresh, :upgrade_existing, :verify_files_present,
190
- :upgrade_installer, :result, :conflicting
254
+ private_class_method :with_lock, :note_newer_installed, :install_fresh, :upgrade_existing, :verify_files_present, :restore_file, :remerge,
255
+ :upgrade_installer, :result, :planned_update
191
256
  end
192
257
  end
193
258
  end
@@ -0,0 +1,399 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tmpdir"
5
+ require_relative "../git_env"
6
+
7
+ module Samagotchi
8
+ module MemoryBundle
9
+ # The text merge of an upgrade: three texts in, the merged text or why
10
+ # there is none out. Writes nothing but its temp copies; Merger::Upgrade
11
+ # asks and Merger.write_merged writes.
12
+ #
13
+ # It runs `git merge-file`. git isn't a chi runtime dependency, so no git,
14
+ # or the macOS stand-in that would pop an install dialog, is a :failed
15
+ # result: the caller keeps the file. Any doubt about what git answered reads
16
+ # the same way, and markers never leave this module.
17
+ #
18
+ # A hunk under a Changelog heading unions, ours then theirs' new entries
19
+ # (#union?), when nothing in or around it could make it more than a list of
20
+ # entries both sides only added to: see Scan. Any other hunk is a real conflict.
21
+ module TextMerge
22
+ MARKER_SIZE = 20
23
+ OURS = "yours"
24
+ THEIRS = "bundle"
25
+ OPEN = "#{"<" * MARKER_SIZE} #{OURS}".freeze
26
+ SEPARATOR = ("=" * MARKER_SIZE).freeze
27
+ BASE = "base"
28
+ BASE_MARK = "#{"|" * MARKER_SIZE} #{BASE}".freeze
29
+ CLOSE = "#{">" * MARKER_SIZE} #{THEIRS}".freeze
30
+ # git merge-file's exit status: the conflicts, capped; 255 is its error.
31
+ MAX_COUNTED = 127
32
+
33
+ # Seconds git may take. It runs under SystemBundle's lock at every chi start.
34
+ TIMEOUT = 10
35
+
36
+ # Where macOS keeps the stand-in, and what says whether it is one.
37
+ APPLE_STUB = "/usr/bin/git"
38
+ XCODE_SELECT = "/usr/bin/xcode-select"
39
+
40
+ NO_GIT = "git not found"
41
+ STUB_GIT = "#{APPLE_STUB} needs the command line tools: xcode-select --install".freeze
42
+ NOT_TEXT = "a side isn't text"
43
+ UNREADABLE = "git's answer couldn't be read"
44
+ NO_BASE = "no recorded base"
45
+ RACED = "the file changed during the merge"
46
+ TIMED_OUT = "git took too long"
47
+
48
+ # heading: the title of the last heading before it, nil when a rule or
49
+ # underline came after that one. ours, base, theirs: its lines on each
50
+ # side. union: it passed Scan's rule.
51
+ Hunk = Data.define(:heading, :ours, :base, :theirs, :union) do
52
+ def union? = union
53
+ end
54
+
55
+ # status: :clean (text is the merged text, hunks the unioned ones),
56
+ # :conflict (hunks has at least one that doesn't union; no text) or
57
+ # :failed (why says why there is no answer).
58
+ Result = Data.define(:status, :text, :hunks, :why) do
59
+ def clean? = status == :clean
60
+ end
61
+
62
+ module_function
63
+
64
+ # @param base [String] the file as last installed
65
+ # @param ours [String] the file on disk
66
+ # @param theirs [String] the bundle's new file
67
+ # @return [Result]
68
+ def merge(base:, ours:, theirs:)
69
+ return failed(NOT_TEXT) if [base, ours, theirs].any? { |text| text.b.include?("\0") }
70
+
71
+ git = find_git
72
+ return failed(NO_GIT) unless git
73
+ return failed(STUB_GIT) if apple_stub?(git)
74
+
75
+ output, code = run_git(git, base, ours, theirs)
76
+ return failed(TIMED_OUT) if code == :timeout
77
+ return failed(UNREADABLE) if code.nil? || code > MAX_COUNTED
78
+
79
+ scan = Scan.new(output)
80
+ return failed(UNREADABLE) unless scan.read? && [scan.hunks.size, MAX_COUNTED].min == code && from_inputs?(scan.lines, base, ours, theirs)
81
+
82
+ if scan.clean?
83
+ Result.new(status: :clean, text: scan.text, hunks: scan.hunks, why: nil)
84
+ else
85
+ Result.new(status: :conflict, text: nil, hunks: scan.hunks, why: nil)
86
+ end
87
+ rescue SystemCallError => e
88
+ failed("git couldn't run: #{e.message}")
89
+ end
90
+
91
+ def failed(why) = Result.new(status: :failed, text: nil, hunks: [], why: why)
92
+
93
+ # What +theirs+ changed against +base+ and +ours+ hasn't got, section by
94
+ # section: what a file that kept its own text has missed. A conflict's hunks
95
+ # can't say that: the whole file stays, so a change git would have merged is
96
+ # missed too, and the one hunk may be a Changelog line beside a whole new
97
+ # section. A section the user already brought over by hand isn't missed, nor
98
+ # one the bundle took out and the user did too.
99
+ # A heading (as written, "## Steps") names its section, the deepest one; a
100
+ # Changelog comes last, being the least of it.
101
+ # @return [Array(Array<String>, Boolean)] the headings; whether text under
102
+ # no heading changed
103
+ def changed_sections(base, theirs, ours)
104
+ before, after, held = [base, theirs, ours].map do |text|
105
+ # Where a section falls leaves blank lines of its own at the end.
106
+ Scan.new(text).sections.map { |heading, lines| [heading, lines.join.strip] }
107
+ end
108
+ headings = (after - before - held).map(&:first)
109
+ gone = (before - after).map(&:first) - after.map(&:first)
110
+ names = (headings + (gone & held.map(&:first))).uniq
111
+ unnamed = names.include?(nil)
112
+ changelog, named = names.compact.partition { |name| name.sub(/\A#+[ \t]*/, "").match?(Scan::CHANGELOG) }
113
+ [named + changelog, unnamed]
114
+ end
115
+
116
+ # Whether git's answer is made of the inputs' lines: anything else (an
117
+ # empty answer to non-empty inputs too) must never replace a memory.
118
+ def from_inputs?(lines, *inputs)
119
+ known = inputs.flat_map { |text| text.b.each_line.map(&:chomp) }.to_h { |line| [line, true] }
120
+ return known.empty? if lines.empty?
121
+
122
+ lines.all? { |line| known.key?(line.chomp) }
123
+ end
124
+
125
+ # The first executable git on PATH, nil without one.
126
+ def find_git(path: ENV.fetch("PATH", ""))
127
+ path.split(File::PATH_SEPARATOR).reject(&:empty?).map { |dir| File.join(dir, "git") }
128
+ .find { |file| File.file?(file) && File.executable?(file) }
129
+ end
130
+
131
+ # Whether +git+ is macOS's stand-in with no developer tools behind it:
132
+ # running it pops a dialog. Unknown counts as yes.
133
+ def apple_stub?(git)
134
+ return false unless darwin? && same_file?(git, APPLE_STUB)
135
+
136
+ dir, status = Open3.capture2e(XCODE_SELECT, "-p")
137
+ !(status.success? && File.directory?(dir.strip))
138
+ rescue SystemCallError
139
+ true
140
+ end
141
+
142
+ def darwin? = RUBY_PLATFORM.include?("darwin")
143
+
144
+ def same_file?(one, other)
145
+ File.identical?(one, other)
146
+ rescue SystemCallError
147
+ false
148
+ end
149
+
150
+ # Temp copies in a temp cwd, with nothing inherited that could point git
151
+ # at a repository or a config: this runs inside other tools' hooks, whose
152
+ # GIT_DIR once defeated the temp cwd.
153
+ # @return [Array(String, Integer)] stdout, the exit status (nil: killed;
154
+ # :timeout: it passed TIMEOUT and was stopped)
155
+ def run_git(git, base, ours, theirs)
156
+ Dir.mktmpdir("chi-merge-") do |dir|
157
+ { OURS => ours, BASE => base, THEIRS => theirs }.each { |name, text| File.binwrite(File.join(dir, name), text) }
158
+ capture_git(git_env(dir), [git, "merge-file", "-p", "--diff3", "--marker-size=#{MARKER_SIZE}",
159
+ "-L", OURS, "-L", BASE, "-L", THEIRS, OURS, BASE, THEIRS], dir)
160
+ end
161
+ end
162
+
163
+ # In a process group of its own: /usr/bin/git may be a launcher with the
164
+ # real git as its child.
165
+ def capture_git(env, command, dir)
166
+ Open3.popen2(env, *command, chdir: dir, err: File::NULL, pgroup: true) do |stdin, stdout, wait|
167
+ stdin.close
168
+ stdout.binmode
169
+ reader = Thread.new { stdout.read }
170
+ unless wait.join(TIMEOUT)
171
+ stop_group(wait.pid)
172
+ reader.kill
173
+ next ["", :timeout]
174
+ end
175
+ [reader.value, wait.value.exitstatus]
176
+ end
177
+ end
178
+
179
+ def stop_group(pid)
180
+ Process.kill("KILL", -pid)
181
+ rescue SystemCallError
182
+ nil
183
+ end
184
+
185
+ def git_env(dir)
186
+ GitEnv::UNSET.merge("GIT_CONFIG_PARAMETERS" => nil, "GIT_CONFIG_COUNT" => nil,
187
+ "GIT_CONFIG_GLOBAL" => File::NULL, "GIT_CONFIG_SYSTEM" => File::NULL, "GIT_CONFIG_NOSYSTEM" => "1",
188
+ "GIT_CEILING_DIRECTORIES" => File.dirname(File.realpath(dir)))
189
+ end
190
+
191
+ # git's output, read a line at a time. Takes the markers out, keeps track
192
+ # of the last heading outside any hunk, code fence and HTML comment, and
193
+ # decides each hunk (D3a of the kept-edits plan). The rule is strict
194
+ # because the naive one merges real rules silently: a fenced "## Changelog"
195
+ # template would swallow what follows it, one hunk can span a Changelog
196
+ # line and a real section, and a paragraph after the entries is no entry.
197
+ # A hunk unions only when
198
+ # - the last heading before it is titled exactly Changelog (or Change
199
+ # log), at level 1 or 2, with no rule or underline after that heading
200
+ # and nothing but list entries between it and the hunk;
201
+ # - no fence or comment is open; and
202
+ # - base, ours and theirs each hold only list entries, and both sides
203
+ # still hold every entry of the base: both only added. An edited or
204
+ # dropped entry is a decision, and so is a base heading both dropped.
205
+ # An entry is a list item and the lines indented deeper right under it,
206
+ # nested items too, none of which, indentation aside, is a heading, a rule
207
+ # or a fence; a blank line ends it. The union is ours, then the entries of
208
+ # theirs that ours doesn't have, compared whole.
209
+ # Marker lines that don't pair up (a user's line equal to one) mean the
210
+ # output can't be read at all.
211
+ class Scan
212
+ HEADING = /\A {0,3}(\#{1,6})(?:[ \t]|\z)/
213
+ RULE = /\A {0,3}(?:=+|-+|\*{3,}|_{3,}|([-*_])(?:[ \t]*\1){2,})[ \t]*\z/
214
+ FENCE = /\A {0,3}(`{3,}|~{3,})/
215
+ LIKE_HEADING = /\A {0,3}#/
216
+ CHANGELOG = /\Achange ?log\z/i
217
+ ITEM = /\A {0,3}(?:[-*+]|\d{1,9}[.)])[ \t]+\S/
218
+ ITEM_MORE = /\A(?: {2,}|\t)\S/
219
+ COMMENT_OPEN = "<!--"
220
+ COMMENT_CLOSE = "-->"
221
+ CHANGELOG_LEVEL = 2
222
+
223
+ attr_reader :hunks, :lines
224
+
225
+ # [heading as written (nil before the first), its lines], in order.
226
+ attr_reader :sections
227
+
228
+ def initialize(output)
229
+ @sections = [[nil, []]]
230
+ @hunks = []
231
+ @pieces = []
232
+ @lines = []
233
+ @heading = nil
234
+ @changelog = false
235
+ @in_item = false
236
+ @fence = nil
237
+ @comment = false
238
+ @read = read_lines(output.b.each_line)
239
+ end
240
+
241
+ def read? = @read
242
+
243
+ def clean? = @hunks.all?(&:union?)
244
+
245
+ def text = @pieces.join
246
+
247
+ private
248
+
249
+ # @return [Boolean] the markers paired up
250
+ def read_lines(lines)
251
+ state = :text
252
+ ours = base = theirs = nil
253
+ lines.each do |line|
254
+ bare = line.chomp
255
+ case state
256
+ when :text
257
+ return false if [BASE_MARK, CLOSE].include?(bare)
258
+
259
+ if bare == OPEN
260
+ ours = []
261
+ state = :ours
262
+ else
263
+ add_text(line, bare)
264
+ end
265
+ when :ours
266
+ return false if [OPEN, SEPARATOR, CLOSE].include?(bare)
267
+
268
+ if bare == BASE_MARK
269
+ base = []
270
+ state = :base
271
+ else
272
+ ours << line
273
+ end
274
+ when :base
275
+ return false if [OPEN, BASE_MARK, CLOSE].include?(bare)
276
+
277
+ if bare == SEPARATOR
278
+ theirs = []
279
+ state = :theirs
280
+ else
281
+ base << line
282
+ end
283
+ when :theirs
284
+ return false if [OPEN, BASE_MARK, SEPARATOR].include?(bare)
285
+
286
+ if bare == CLOSE
287
+ add_hunk(ours, base, theirs)
288
+ state = :text
289
+ else
290
+ theirs << line
291
+ end
292
+ end
293
+ end
294
+ state == :text
295
+ end
296
+
297
+ def add_text(line, bare)
298
+ @pieces << line
299
+ @lines << line
300
+ classify_text(bare)
301
+ @sections.last.last << line
302
+ end
303
+
304
+ def classify_text(bare)
305
+ return follow_fence(bare) if @fence || bare.match?(FENCE)
306
+ return follow_comment(bare) if @comment || bare.include?(COMMENT_OPEN)
307
+
308
+ if bare.match?(HEADING) then add_heading(bare)
309
+ elsif bare.match?(RULE) then @heading = nil
310
+ else follow_list(bare)
311
+ end
312
+ end
313
+
314
+ def add_heading(bare)
315
+ # Reports and the record take these: the scan is binary, and they need text.
316
+ @heading = text_of(bare.sub(/\A {0,3}\#{1,6}[ \t]*/, "").sub(/[ \t#:]*\z/, ""))
317
+ @changelog = bare[HEADING, 1].size <= CHANGELOG_LEVEL && @heading.match?(CHANGELOG)
318
+ @in_item = false
319
+ @sections << [text_of(bare.strip.sub(/[ \t#:]*\z/, "")), []]
320
+ end
321
+
322
+ # Whether the section so far is still nothing but list entries.
323
+ def follow_list(bare)
324
+ return @in_item = false if bare.strip.empty?
325
+
326
+ @in_item = bare.match?(ITEM) || (@in_item && more_of_entry?(bare))
327
+ @changelog = false unless @in_item
328
+ end
329
+
330
+ # An open fence closes on a line of its own character, at least as
331
+ # long, with nothing after it.
332
+ def follow_fence(bare)
333
+ @changelog = false
334
+ fence = bare[FENCE, 1]
335
+ return @fence = fence unless @fence
336
+ return unless fence && fence[0] == @fence[0] && fence.size >= @fence.size
337
+
338
+ @fence = nil if bare.strip == fence
339
+ end
340
+
341
+ def follow_comment(bare)
342
+ @changelog = false
343
+ after = @comment ? bare : bare[(bare.index(COMMENT_OPEN) + COMMENT_OPEN.size)..]
344
+ @comment = !after.include?(COMMENT_CLOSE)
345
+ end
346
+
347
+ def add_hunk(ours, base, theirs)
348
+ @lines.concat(ours, base, theirs)
349
+ union = union_of(ours, base, theirs)
350
+ @hunks << Hunk.new(heading: @heading, ours: ours, base: base, theirs: theirs, union: !union.nil?)
351
+ return unless union
352
+
353
+ @pieces.concat(union)
354
+ @in_item = true
355
+ end
356
+
357
+ # The union's lines, nil when this hunk isn't one.
358
+ def union_of(ours, base, theirs)
359
+ return nil if @fence || @comment || !@changelog || !@heading.to_s.match?(CHANGELOG)
360
+
361
+ ours_entries = entries(ours)
362
+ base_entries = entries(base)
363
+ theirs_entries = entries(theirs)
364
+ return nil unless ours_entries && base_entries && theirs_entries
365
+ return nil unless (base_entries - ours_entries).empty? && (base_entries - theirs_entries).empty?
366
+
367
+ ours + (theirs_entries - ours_entries).flatten
368
+ end
369
+
370
+ # The side's list entries, each a list of lines; nil when it holds
371
+ # anything else.
372
+ def entries(lines)
373
+ attached = false
374
+ lines.each_with_object([]) do |line, found|
375
+ bare = line.chomp
376
+ if bare.strip.empty?
377
+ attached = false
378
+ elsif structural?(bare.lstrip) then return nil
379
+ elsif attached && bare.match?(ITEM_MORE) && indent(bare) > indent(found.last.first) then found.last << line
380
+ elsif bare.match?(ITEM)
381
+ found << [line]
382
+ attached = true
383
+ else return nil
384
+ end
385
+ end
386
+ end
387
+
388
+ def more_of_entry?(bare) = bare.match?(ITEM_MORE) && !structural?(bare.lstrip)
389
+
390
+ def indent(line) = line[/\A[ \t]*/].size
391
+
392
+ # As UTF-8, what isn't valid replaced: a heading is for reading.
393
+ def text_of(bytes) = bytes.dup.force_encoding(Encoding::UTF_8).scrub
394
+
395
+ def structural?(text) = text.match?(LIKE_HEADING) || text.match?(RULE) || text.match?(FENCE)
396
+ end
397
+ end
398
+ end
399
+ end