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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -1
- data/README.md +4 -2
- data/docs/architecture.md +2 -2
- data/docs/cli.md +59 -24
- data/docs/configuration.md +5 -5
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bridge.md +8 -0
- data/docs/internals/bundles.md +42 -14
- data/docs/internals/objectives.md +7 -2
- data/docs/internals/web-frontend.md +1 -1
- data/docs/internals/web-push.md +2 -2
- data/docs/internals/working-set.md +5 -2
- data/docs/memory.md +47 -9
- data/docs/plugins.md +7 -1
- data/docs/sessions.md +5 -4
- data/docs/testing.md +5 -0
- data/lib/samagotchi/atomic_file.rb +3 -0
- data/lib/samagotchi/bootstrap_command.rb +5 -2
- data/lib/samagotchi/bridge/card_store.rb +52 -7
- data/lib/samagotchi/bridge.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +91 -68
- data/lib/samagotchi/bundler_env.rb +24 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -1
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -1
- data/lib/samagotchi/bundles/known-names/manifest.yml +3 -2
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +2 -2
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/chi_home.rb +4 -2
- data/lib/samagotchi/chi_shim.rb +2 -1
- data/lib/samagotchi/child_env.rb +10 -3
- data/lib/samagotchi/engine.rb +1 -1
- data/lib/samagotchi/git_env.rb +43 -0
- data/lib/samagotchi/guardrails/context.rb +2 -0
- data/lib/samagotchi/guardrails/targets.rb +4 -1
- data/lib/samagotchi/memory_bundle/asset_installer.rb +16 -2
- data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +62 -1
- data/lib/samagotchi/memory_bundle/installer.rb +161 -52
- data/lib/samagotchi/memory_bundle/listing.rb +4 -3
- data/lib/samagotchi/memory_bundle/merger.rb +209 -8
- data/lib/samagotchi/memory_bundle/placeholder.rb +2 -1
- data/lib/samagotchi/memory_bundle/profile.rb +7 -3
- data/lib/samagotchi/memory_bundle/provenance.rb +178 -20
- data/lib/samagotchi/memory_bundle/remerge.rb +167 -0
- data/lib/samagotchi/memory_bundle/shipped_history.rb +79 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +89 -24
- data/lib/samagotchi/memory_bundle/source.rb +3 -3
- data/lib/samagotchi/memory_bundle/status.rb +21 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +98 -33
- data/lib/samagotchi/memory_bundle/text_merge.rb +399 -0
- data/lib/samagotchi/memory_bundle/trash.rb +58 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +26 -6
- data/lib/samagotchi/objectives_check.rb +37 -7
- data/lib/samagotchi/project_env.rb +3 -2
- data/lib/samagotchi/self_report.rb +2 -0
- data/lib/samagotchi/testing/boundary.rb +1 -0
- data/lib/samagotchi/testing/minitest.rb +3 -0
- data/lib/samagotchi/testing/rspec.rb +3 -0
- data/lib/samagotchi/testing/sandbox.rb +3 -1
- data/lib/samagotchi/tools/delegate.rb +2 -1
- data/lib/samagotchi/tools/task_runtime.rb +13 -10
- data/lib/samagotchi/turn_flow.rb +3 -1
- data/lib/samagotchi/update_command.rb +76 -28
- data/lib/samagotchi/update_hint.rb +4 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/auto_update.rb +8 -8
- data/lib/samagotchi/web/auto_update_record.rb +6 -4
- data/lib/samagotchi/web/participant_view.rb +1 -1
- data/lib/samagotchi/web/public/app.js +5 -3
- data/lib/samagotchi/web/public/block_open.js +13 -0
- data/lib/samagotchi/web/public/bubbles.js +4 -2
- data/lib/samagotchi/web/public/cards_place.js +10 -5
- data/lib/samagotchi/web/public/details_motion.js +2 -1
- data/lib/samagotchi/web/public/index.html +18 -14
- data/lib/samagotchi/web/public/stage_model.js +5 -6
- data/lib/samagotchi/web/public/stage_view.js +8 -7
- data/lib/samagotchi/web/public/turn_block.js +34 -19
- data/lib/samagotchi/web/public/turn_model.js +1 -1
- data/lib/samagotchi/web/public/update_sheet.js +8 -3
- data/lib/samagotchi/web/public/working_set_view.js +10 -7
- data/lib/samagotchi/web/push/notifier.rb +7 -9
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/update_ops.rb +2 -1
- metadata +8 -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,
|
|
20
|
-
#
|
|
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), :
|
|
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);
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
52
|
-
|
|
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 ?
|
|
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
|
-
|
|
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,
|
|
96
|
-
Result.new(from: from, to: to, status: status,
|
|
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
|
|
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
|
-
|
|
157
|
-
warnings = installer.warnings.reject { |w|
|
|
158
|
-
result(:updated, from: from, to: to,
|
|
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
|
|
163
|
-
#
|
|
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.
|
|
167
|
-
return result(:up_to_date, from: version, to: version)
|
|
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
|
-
|
|
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
|
-
#
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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, :
|
|
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
|