mxup 0.3.1 → 1.0.1
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/README.md +254 -40
- data/bin/mxup +6 -1
- data/examples/myapp-dev.yml +49 -14
- data/lib/mxup/cli.rb +10 -7
- data/lib/mxup/config.rb +409 -62
- data/lib/mxup/migrations.rb +109 -0
- data/lib/mxup/reconciler.rb +4 -2
- data/lib/mxup/runner.rb +10 -9
- data/lib/mxup/status_view.rb +1 -1
- data/lib/mxup/version.rb +1 -1
- data/lib/mxup.rb +2 -0
- metadata +3 -2
data/lib/mxup/config.rb
CHANGED
|
@@ -7,26 +7,57 @@ require_relative 'env_file'
|
|
|
7
7
|
module Mxup
|
|
8
8
|
# Parsed mxup YAML config. Pure data; no tmux or filesystem side effects.
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
10
|
+
# Config format generations: `mxup_version` names the generation a config was
|
|
11
|
+
# written for, and mxup reads every generation up to its own — so upgrading
|
|
12
|
+
# mxup never forces a config rewrite. The declared major selects how
|
|
13
|
+
# `profiles:` is read; nothing else in the format differs between them.
|
|
14
|
+
#
|
|
15
|
+
# Profiles (optional): a config may declare a `profiles:` map. What selecting
|
|
16
|
+
# one means depends on the generation:
|
|
17
|
+
#
|
|
18
|
+
# * Format 1 (`mxup_version: '1'`) — *additive*, and any number of profiles
|
|
19
|
+
# may be selected at once. The base `windows:` block is a catalog, and a
|
|
20
|
+
# window only runs when at least one selected profile opts in to it.
|
|
21
|
+
# Several profiles opting in to the same window is fine; two of them
|
|
22
|
+
# overriding the same setting differently is a collision and aborts with an
|
|
23
|
+
# explanation.
|
|
24
|
+
# * Format 0 (no `mxup_version` — every config written for 0.3.1 or earlier)
|
|
25
|
+
# — *subtractive*, one profile at a time. Every window in `windows:` runs;
|
|
26
|
+
# the active profile overrides some of them and drops others with `~`.
|
|
27
|
+
#
|
|
28
|
+
# Either way the merged result is applied before the rest of the Config is
|
|
29
|
+
# built — so the rest of the system (Launcher, Reconciler, StatusView…) never
|
|
30
|
+
# has to know about profiles or generations.
|
|
16
31
|
class Config
|
|
17
|
-
|
|
32
|
+
# Config format generations this mxup can read. Generation N ships in mxup
|
|
33
|
+
# N.0.0 and every older one keeps working, so this range only ever grows.
|
|
34
|
+
CURRENT_FORMAT_MAJOR = 1
|
|
35
|
+
SUPPORTED_FORMAT_MAJORS = (0..CURRENT_FORMAT_MAJOR).freeze
|
|
36
|
+
|
|
37
|
+
KNOWN_TOP_LEVEL_KEYS = %w[mxup_version session setup root live_env required_env windows layouts profiles default_profile].freeze
|
|
18
38
|
KNOWN_WINDOW_KEYS = %w[root command env wait_for live_env commands].freeze
|
|
19
39
|
KNOWN_LAYOUT_GROUP_KEYS = %w[panes split].freeze
|
|
20
40
|
KNOWN_PROFILE_KEYS = %w[setup root layouts live_env windows].freeze
|
|
21
41
|
|
|
42
|
+
# Profile keys that replace their base value wholesale, so two profiles
|
|
43
|
+
# declaring them differently cannot be reconciled.
|
|
44
|
+
PROFILE_SCALAR_KEYS = %w[setup root layouts].freeze
|
|
45
|
+
|
|
46
|
+
# Window keys that are themselves maps, compared entry-by-entry so two
|
|
47
|
+
# profiles can each contribute a different env var to the same window.
|
|
48
|
+
NESTED_WINDOW_KEYS = %w[env commands].freeze
|
|
49
|
+
|
|
22
50
|
attr_reader :session, :setup, :root, :windows, :layouts, :layout_names,
|
|
23
|
-
:
|
|
24
|
-
:live_env_dir, :required_env, :env_file_path
|
|
51
|
+
:profiles, :profile_names, :default_profiles, :live_env,
|
|
52
|
+
:live_env_dir, :required_env, :env_file_path, :format_major
|
|
25
53
|
|
|
26
|
-
|
|
54
|
+
# `profiles` accepts nil, a single name, or a list; `-p a,b` and
|
|
55
|
+
# `-p a -p b` both arrive here as the same selection.
|
|
56
|
+
def initialize(path, profiles: nil)
|
|
27
57
|
raw = YAML.safe_load(File.read(path), permitted_classes: [Symbol])
|
|
58
|
+
@format_major = resolve_format_major!(raw)
|
|
28
59
|
validate_keys!(raw, KNOWN_TOP_LEVEL_KEYS, 'top level')
|
|
29
|
-
|
|
60
|
+
resolve_profiles!(raw, profiles)
|
|
30
61
|
@session = raw.fetch('session')
|
|
31
62
|
@setup = raw['setup']&.strip
|
|
32
63
|
@live_env_dir = config_base_dir(path)
|
|
@@ -36,10 +67,17 @@ module Mxup
|
|
|
36
67
|
load_env_file_into_env!
|
|
37
68
|
@root = resolve_root(raw['root'], path)
|
|
38
69
|
@windows = parse_windows(raw.fetch('windows'))
|
|
70
|
+
validate_windows_present!
|
|
39
71
|
validate_window_live_env!
|
|
40
72
|
@layouts, @layout_names = parse_layouts(raw['layouts'])
|
|
41
73
|
end
|
|
42
74
|
|
|
75
|
+
# The selection as stored in the session's tmux environment (MXUP_PROFILE),
|
|
76
|
+
# so `status` can display it and `up` can detect a switch.
|
|
77
|
+
def profiles_label
|
|
78
|
+
@profiles.join(',')
|
|
79
|
+
end
|
|
80
|
+
|
|
43
81
|
def default_layout
|
|
44
82
|
@layout_names.first
|
|
45
83
|
end
|
|
@@ -78,98 +116,407 @@ module Mxup
|
|
|
78
116
|
|
|
79
117
|
private
|
|
80
118
|
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
119
|
+
# A config declares the format generation it was written for, and mxup
|
|
120
|
+
# reads every generation up to its own. The declared major therefore
|
|
121
|
+
# *selects* the profile semantics rather than gating the config out, so an
|
|
122
|
+
# mxup upgrade never forces a config rewrite; only a config aimed at a
|
|
123
|
+
# newer mxup is refused. Resolved before any other validation, so such a
|
|
124
|
+
# config explains itself instead of tripping over a key it doesn't know.
|
|
125
|
+
def resolve_format_major!(raw)
|
|
126
|
+
@format_pinned = false
|
|
127
|
+
return CURRENT_FORMAT_MAJOR unless raw.is_a?(Hash)
|
|
128
|
+
|
|
129
|
+
declared = raw['mxup_version']
|
|
130
|
+
# No `mxup_version` means the config predates the key, which only existed
|
|
131
|
+
# from major 1 on — so it is read as the original format, major 0.
|
|
132
|
+
@format_pinned = !declared.nil?
|
|
133
|
+
wanted = declared.nil? ? 0 : parse_mxup_major(declared)
|
|
134
|
+
return wanted if SUPPORTED_FORMAT_MAJORS.include?(wanted)
|
|
135
|
+
|
|
136
|
+
raise ArgumentError, format_from_the_future(wanted)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def legacy_format?
|
|
140
|
+
@format_major.zero?
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def parse_mxup_major(declared)
|
|
144
|
+
# Guarded explicitly: Gem::Version reads a blank string as 0, which would
|
|
145
|
+
# quietly pass as "major 0" instead of flagging the empty value.
|
|
146
|
+
if declared.to_s.strip.empty?
|
|
147
|
+
raise ArgumentError, "mxup_version must name a major version, e.g. '1'"
|
|
148
|
+
end
|
|
149
|
+
unless Gem::Version.correct?(declared.to_s)
|
|
150
|
+
raise ArgumentError, "mxup_version '#{declared}' is not a valid version"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Only the major is compared, so anything after it is decoration: '2',
|
|
154
|
+
# '2.0' and '2.7.3' all pin the same format generation.
|
|
155
|
+
Gem::Version.new(declared.to_s).segments.first
|
|
156
|
+
end
|
|
87
157
|
|
|
88
|
-
|
|
89
|
-
|
|
158
|
+
# Only reachable for a generation newer than this mxup knows: older ones
|
|
159
|
+
# are read, not refused.
|
|
160
|
+
def format_from_the_future(wanted)
|
|
161
|
+
<<~MSG.chomp
|
|
162
|
+
This config targets the mxup #{wanted}.x config format, but mxup #{VERSION}
|
|
163
|
+
reads up to #{CURRENT_FORMAT_MAJOR}.x. Update mxup, then try again:
|
|
164
|
+
brew upgrade mxup # if installed via Homebrew
|
|
165
|
+
gem update mxup # if installed via RubyGems
|
|
166
|
+
MSG
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Resolve the selected profiles and merge them into `raw`.
|
|
170
|
+
# Sets @profiles / @profile_names / @default_profiles.
|
|
171
|
+
def resolve_profiles!(raw, requested)
|
|
172
|
+
declared = raw['profiles'] || {}
|
|
173
|
+
@profile_names = declared.keys
|
|
174
|
+
@default_profiles = Array(raw['default_profile']).map(&:to_s)
|
|
175
|
+
selected = normalize_profile_selection(requested)
|
|
176
|
+
|
|
177
|
+
if declared.empty?
|
|
178
|
+
unless selected.empty?
|
|
90
179
|
raise ArgumentError,
|
|
91
|
-
"--profile '#{
|
|
180
|
+
"--profile '#{selected.join(', ')}' was given, but this config " \
|
|
181
|
+
'declares no profiles'
|
|
92
182
|
end
|
|
93
|
-
@
|
|
183
|
+
@profiles = []
|
|
94
184
|
return
|
|
95
185
|
end
|
|
96
186
|
|
|
97
|
-
|
|
98
|
-
|
|
187
|
+
validate_legacy_selection!(selected) if legacy_format?
|
|
188
|
+
@profiles = selected.empty? ? default_profile_selection : selected
|
|
189
|
+
|
|
190
|
+
unknown = @profiles - @profile_names
|
|
191
|
+
unless unknown.empty?
|
|
99
192
|
raise ArgumentError,
|
|
100
|
-
"Unknown profile '#{
|
|
193
|
+
"Unknown profile(s) #{unknown.map { |n| "'#{n}'" }.join(', ')} " \
|
|
194
|
+
"(available: #{@profile_names.join(', ')})"
|
|
101
195
|
end
|
|
102
196
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return unless override.is_a?(Hash)
|
|
197
|
+
overrides = @profiles.map do |n|
|
|
198
|
+
override = declared.fetch(n)
|
|
199
|
+
[n, override.is_a?(Hash) ? override : {}]
|
|
200
|
+
end
|
|
108
201
|
|
|
109
|
-
|
|
202
|
+
if legacy_format?
|
|
203
|
+
warn_legacy_profiles unless @format_pinned
|
|
204
|
+
apply_legacy_profile!(raw, *overrides.first)
|
|
205
|
+
else
|
|
206
|
+
apply_profiles!(raw, overrides)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
110
209
|
|
|
111
|
-
|
|
210
|
+
# Selecting several profiles at once, and pointing `default_profile` at
|
|
211
|
+
# several, both arrived with format 1. Under format 0 exactly one profile is
|
|
212
|
+
# active, so say what to change instead of quietly using just the first.
|
|
213
|
+
def validate_legacy_selection!(selected)
|
|
214
|
+
if selected.size > 1
|
|
112
215
|
raise ArgumentError,
|
|
113
|
-
|
|
114
|
-
|
|
216
|
+
legacy_single_profile_message(
|
|
217
|
+
"--profile '#{selected.join(', ')}' selects #{selected.size} profiles"
|
|
218
|
+
)
|
|
115
219
|
end
|
|
220
|
+
return unless @default_profiles.size > 1
|
|
116
221
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
222
|
+
raise ArgumentError,
|
|
223
|
+
legacy_single_profile_message(
|
|
224
|
+
"default_profile lists #{@default_profiles.size} profiles"
|
|
225
|
+
)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def legacy_single_profile_message(subject)
|
|
229
|
+
<<~MSG.chomp
|
|
230
|
+
#{subject}, which the mxup 1.x config format added.
|
|
231
|
+
This config is read as the 0.x format, where one profile is active at a
|
|
232
|
+
time. Run `mxup migrations` to see what 1.x changed, then set
|
|
233
|
+
`mxup_version: '1'` to select several at once.
|
|
234
|
+
MSG
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Format 0 and format 1 read the same `profiles:` text differently, and this
|
|
238
|
+
# is the only place they diverge — so a config that hasn't opted in is told
|
|
239
|
+
# which meaning it is getting. Two cases stay quiet: a config without
|
|
240
|
+
# `profiles:` (the generations are identical for it, so there is nothing to
|
|
241
|
+
# do) and one that pins `mxup_version: '0'`, which is an informed choice
|
|
242
|
+
# rather than a config that never heard about the key.
|
|
243
|
+
def warn_legacy_profiles
|
|
244
|
+
warn <<~MSG.chomp
|
|
245
|
+
mxup: reading `profiles:` with the 0.x meaning (every window in `windows:` runs;
|
|
246
|
+
the profile overrides some and drops others with `~`). Run `mxup migrations`,
|
|
247
|
+
then set `mxup_version: '1'` to switch to additive profiles.
|
|
248
|
+
MSG
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Format 0 profile semantics, kept so configs written for mxup 0.3.1 and
|
|
252
|
+
# earlier keep running unchanged: the profile is a partial override on the
|
|
253
|
+
# base config, and every window it does not mention still runs.
|
|
254
|
+
def apply_legacy_profile!(raw, name, override)
|
|
255
|
+
validate_profile!(name, override)
|
|
256
|
+
|
|
257
|
+
PROFILE_SCALAR_KEYS.each { |key| raw[key] = override[key] if override.key?(key) }
|
|
120
258
|
|
|
121
259
|
if override.key?('live_env')
|
|
122
|
-
|
|
123
|
-
prof_le = override['live_env'] || {}
|
|
124
|
-
raw['live_env'] = base_le.merge(prof_le)
|
|
260
|
+
raw['live_env'] = (raw['live_env'] || {}).merge(override['live_env'] || {})
|
|
125
261
|
end
|
|
126
262
|
|
|
263
|
+
apply_legacy_windows!(raw, override['windows'] || {})
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# `~` drops a window for this profile; any other value is merged onto the
|
|
267
|
+
# base entry, adding the window when the base declares none.
|
|
268
|
+
def apply_legacy_windows!(raw, windows)
|
|
269
|
+
return if windows.empty?
|
|
270
|
+
|
|
271
|
+
raw['windows'] ||= {}
|
|
127
272
|
removed = []
|
|
128
|
-
|
|
129
|
-
|
|
273
|
+
|
|
274
|
+
windows.each do |wname, woverride|
|
|
130
275
|
if woverride.nil?
|
|
131
|
-
# Explicit null ("dev-kit: ~") drops this window for this profile.
|
|
132
|
-
# `parse_windows` skips absent keys; `prune_layouts!` keeps layouts
|
|
133
|
-
# from referencing the now-missing name.
|
|
134
276
|
raw['windows'].delete(wname)
|
|
135
277
|
removed << wname
|
|
136
278
|
else
|
|
137
|
-
|
|
138
|
-
raw['windows'][wname] = merge_window(base, woverride)
|
|
279
|
+
raw['windows'][wname] = merge_legacy_window(raw['windows'][wname] || {}, woverride)
|
|
139
280
|
end
|
|
140
281
|
end
|
|
141
282
|
|
|
142
|
-
|
|
283
|
+
prune_removed_from_layouts!(raw, removed)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Shallow merge, except that `env` and `commands` are themselves maps and
|
|
287
|
+
# merge entry by entry — so a profile can tweak one of their keys without
|
|
288
|
+
# redeclaring the rest.
|
|
289
|
+
def merge_legacy_window(base, override)
|
|
290
|
+
base.merge(override) do |key, base_val, prof_val|
|
|
291
|
+
if NESTED_WINDOW_KEYS.include?(key) && base_val.is_a?(Hash) && prof_val.is_a?(Hash)
|
|
292
|
+
base_val.merge(prof_val)
|
|
293
|
+
else
|
|
294
|
+
prof_val
|
|
295
|
+
end
|
|
296
|
+
end
|
|
143
297
|
end
|
|
144
298
|
|
|
145
|
-
# Strip
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
|
|
299
|
+
# Strip only the windows this profile dropped from layout groups, so
|
|
300
|
+
# parse_layouts still reports a pane naming a window that was never in
|
|
301
|
+
# `windows:` as the typo it is — as 0.3.1 did. (The format 1 path prunes by
|
|
302
|
+
# what was *kept*, because there a window is absent until a profile opts in.)
|
|
303
|
+
def prune_removed_from_layouts!(raw, removed)
|
|
304
|
+
return if removed.empty?
|
|
149
305
|
return unless raw['layouts'].is_a?(Hash)
|
|
150
|
-
|
|
306
|
+
dropped = removed.to_set
|
|
151
307
|
|
|
152
308
|
raw['layouts'].each_value do |layout_def|
|
|
153
309
|
next unless layout_def.is_a?(Hash)
|
|
154
310
|
layout_def.reject! do |_group_name, group_def|
|
|
155
311
|
next false unless group_def.is_a?(Hash) && group_def['panes'].is_a?(Array)
|
|
156
|
-
group_def['panes']
|
|
312
|
+
group_def['panes'].reject! { |pn| dropped.include?(pn) }
|
|
157
313
|
group_def['panes'].empty?
|
|
158
314
|
end
|
|
159
315
|
end
|
|
160
316
|
end
|
|
161
317
|
|
|
162
|
-
#
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
318
|
+
# Duplicates collapse — naming the same profile twice is not a collision
|
|
319
|
+
# with itself.
|
|
320
|
+
def normalize_profile_selection(requested)
|
|
321
|
+
Array(requested)
|
|
322
|
+
.flat_map { |p| p.to_s.split(',') }
|
|
323
|
+
.map(&:strip).reject(&:empty?).uniq
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def default_profile_selection
|
|
327
|
+
@default_profiles.empty? ? [@profile_names.first] : @default_profiles
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Merge every selected profile into `raw`, collecting disagreements as we
|
|
331
|
+
# go so a config with several conflicts reports all of them at once rather
|
|
332
|
+
# than one per run.
|
|
333
|
+
def apply_profiles!(raw, selected)
|
|
334
|
+
selected.each { |name, override| validate_profile!(name, override) }
|
|
335
|
+
|
|
336
|
+
collisions = []
|
|
337
|
+
claims = {}
|
|
338
|
+
|
|
339
|
+
apply_profile_scalars!(raw, selected, claims, collisions)
|
|
340
|
+
apply_profile_live_env!(raw, selected, claims, collisions)
|
|
341
|
+
windows = resolve_profile_windows(raw, selected, claims, collisions)
|
|
342
|
+
|
|
343
|
+
raise ArgumentError, collision_report(collisions) if collisions.any?
|
|
344
|
+
|
|
345
|
+
raw['windows'] = windows
|
|
346
|
+
prune_layouts!(raw, windows.keys)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def validate_profile!(name, override)
|
|
350
|
+
validate_keys!(override, KNOWN_PROFILE_KEYS, "profile '#{name}'")
|
|
351
|
+
|
|
352
|
+
return unless override.key?('session')
|
|
353
|
+
raise ArgumentError,
|
|
354
|
+
"Profile '#{name}' cannot override 'session'; profiles of the " \
|
|
355
|
+
'same group must share one tmux session name'
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def apply_profile_scalars!(raw, selected, claims, collisions)
|
|
359
|
+
PROFILE_SCALAR_KEYS.each do |key|
|
|
360
|
+
selected.each do |name, override|
|
|
361
|
+
next unless override.key?(key)
|
|
362
|
+
claim!(claims, collisions, "top-level '#{key}'", name, override[key])
|
|
363
|
+
raw[key] = override[key]
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Profile live_env entries override the base per variable, so two profiles
|
|
369
|
+
# collide only when they declare the *same* variable differently.
|
|
370
|
+
def apply_profile_live_env!(raw, selected, claims, collisions)
|
|
371
|
+
merged = (raw['live_env'] || {}).dup
|
|
372
|
+
|
|
373
|
+
selected.each do |name, override|
|
|
374
|
+
(override['live_env'] || {}).each do |var, command|
|
|
375
|
+
claim!(claims, collisions, "live_env '#{var}'", name, command)
|
|
376
|
+
merged[var] = command
|
|
171
377
|
end
|
|
172
378
|
end
|
|
379
|
+
|
|
380
|
+
raw['live_env'] = merged unless merged.empty?
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Build the effective `windows:` map from the selected profiles. A window
|
|
384
|
+
# is included when at least one profile lists it with a (possibly empty)
|
|
385
|
+
# override; mapping it to null ("db: ~") is an explicit veto, which
|
|
386
|
+
# collides with any profile that includes the same window.
|
|
387
|
+
def resolve_profile_windows(raw, selected, claims, collisions)
|
|
388
|
+
catalog = raw['windows'] || {}
|
|
389
|
+
vetoes = {}
|
|
390
|
+
includes = {}
|
|
391
|
+
|
|
392
|
+
selected.each do |name, override|
|
|
393
|
+
(override['windows'] || {}).each do |wname, woverride|
|
|
394
|
+
if woverride.nil?
|
|
395
|
+
(vetoes[wname] ||= []) << name
|
|
396
|
+
else
|
|
397
|
+
(includes[wname] ||= []) << [name, woverride]
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
flag_veto_collisions(vetoes, includes, collisions)
|
|
403
|
+
|
|
404
|
+
order_included_windows(catalog, includes).each_with_object({}) do |wname, acc|
|
|
405
|
+
acc[wname] = merge_profile_window(
|
|
406
|
+
wname, catalog[wname] || {}, includes[wname], claims, collisions
|
|
407
|
+
)
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def flag_veto_collisions(vetoes, includes, collisions)
|
|
412
|
+
includes.each_key do |wname|
|
|
413
|
+
vetoed_by = vetoes[wname]
|
|
414
|
+
next unless vetoed_by
|
|
415
|
+
|
|
416
|
+
collisions << {
|
|
417
|
+
label: "window '#{wname}'",
|
|
418
|
+
text: "vetoed by #{vetoed_by.join(', ')}, but included by " \
|
|
419
|
+
"#{includes[wname].map(&:first).join(', ')}"
|
|
420
|
+
}
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Catalog order first, so window and pane ordering stays stable no matter
|
|
425
|
+
# which profile happened to mention a window first. Windows a profile
|
|
426
|
+
# declares from scratch follow, in the order they were encountered.
|
|
427
|
+
def order_included_windows(catalog, includes)
|
|
428
|
+
catalog.keys.select { |n| includes.key?(n) } + (includes.keys - catalog.keys)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Layer every including profile's override onto the catalog entry. Keys are
|
|
432
|
+
# claimed one at a time (and `env`/`commands` entry by entry) so profiles
|
|
433
|
+
# touching different settings of the same window merge cleanly.
|
|
434
|
+
def merge_profile_window(wname, base, entries, claims, collisions)
|
|
435
|
+
entries.each_with_object(base.dup) do |(pname, override), merged|
|
|
436
|
+
override.each do |key, value|
|
|
437
|
+
if NESTED_WINDOW_KEYS.include?(key) && value.is_a?(Hash)
|
|
438
|
+
nested = (merged[key] || {}).dup
|
|
439
|
+
value.each do |nkey, nvalue|
|
|
440
|
+
claim!(claims, collisions, "window '#{wname}' → #{key}.#{nkey}", pname, nvalue)
|
|
441
|
+
nested[nkey] = nvalue
|
|
442
|
+
end
|
|
443
|
+
merged[key] = nested
|
|
444
|
+
else
|
|
445
|
+
claim!(claims, collisions, "window '#{wname}' → #{key}", pname, value)
|
|
446
|
+
merged[key] = value
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Record "profile P set LABEL to VALUE". A later profile setting the same
|
|
453
|
+
# label to a *different* value is the collision we report; setting it to
|
|
454
|
+
# the same value silently agrees.
|
|
455
|
+
def claim!(claims, collisions, label, profile, value)
|
|
456
|
+
previous = claims[label]
|
|
457
|
+
if previous && previous.last != value
|
|
458
|
+
collisions << { label: label, first: previous, second: [profile, value] }
|
|
459
|
+
end
|
|
460
|
+
claims[label] = [profile, value]
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def collision_report(collisions)
|
|
464
|
+
lines = ["Profile collision: the selected profiles " \
|
|
465
|
+
"(#{@profiles.join(', ')}) disagree.", '']
|
|
466
|
+
|
|
467
|
+
collisions.each do |c|
|
|
468
|
+
lines.concat(c[:text] ? [" #{c[:label]}: #{c[:text]}"] : disagreement_lines(c))
|
|
469
|
+
lines << ''
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
lines << 'Select fewer profiles, or make the conflicting values agree.'
|
|
473
|
+
lines.join("\n")
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def disagreement_lines(collision)
|
|
477
|
+
first, second = collision.values_at(:first, :second)
|
|
478
|
+
width = [first.first.length, second.first.length].max
|
|
479
|
+
|
|
480
|
+
[" #{collision[:label]}"] + [first, second].map do |profile, value|
|
|
481
|
+
" #{profile.ljust(width)} sets #{format_collision_value(value)}"
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def format_collision_value(value)
|
|
486
|
+
text = value.inspect
|
|
487
|
+
text.length > 70 ? "#{text[0, 67]}..." : text
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Strip window names that no selected profile included from layout groups
|
|
491
|
+
# so parse_layouts doesn't raise "window not found". A group whose panes
|
|
492
|
+
# list becomes empty is dropped from the layout entirely.
|
|
493
|
+
def prune_layouts!(raw, kept_names)
|
|
494
|
+
return unless raw['layouts'].is_a?(Hash)
|
|
495
|
+
kept = kept_names.to_set
|
|
496
|
+
|
|
497
|
+
raw['layouts'].each_value do |layout_def|
|
|
498
|
+
next unless layout_def.is_a?(Hash)
|
|
499
|
+
layout_def.reject! do |_group_name, group_def|
|
|
500
|
+
next false unless group_def.is_a?(Hash) && group_def['panes'].is_a?(Array)
|
|
501
|
+
group_def['panes'].select! { |pn| kept.include?(pn) }
|
|
502
|
+
group_def['panes'].empty?
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Under additive profiles an empty selection is a realistic mistake (a
|
|
508
|
+
# profile that declares no `windows:` runs nothing), so say so plainly
|
|
509
|
+
# instead of letting the reconciler fail on a session with zero windows.
|
|
510
|
+
def validate_windows_present!
|
|
511
|
+
return unless @windows.empty?
|
|
512
|
+
|
|
513
|
+
if @profiles.any? && !legacy_format?
|
|
514
|
+
raise ArgumentError,
|
|
515
|
+
"Profile(s) #{@profiles.join(', ')} include no windows — nothing " \
|
|
516
|
+
'would run. Each profile lists the windows it runs under `windows:`.'
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
raise ArgumentError, 'Config declares no windows'
|
|
173
520
|
end
|
|
174
521
|
|
|
175
522
|
def parse_windows(hash)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mxup
|
|
4
|
+
# The config format's history, one entry per major version, plus the renderer
|
|
5
|
+
# behind `mxup migrations`.
|
|
6
|
+
#
|
|
7
|
+
# A new config format generation ships with a new mxup major, and mxup goes on
|
|
8
|
+
# reading every older generation (see Config#resolve_format_major!) — so every
|
|
9
|
+
# major above the first has exactly one entry here describing what changed and
|
|
10
|
+
# how to move a config written for the previous one. When cutting a new major,
|
|
11
|
+
# add an entry and bump Config::CURRENT_FORMAT_MAJOR to match its `to`.
|
|
12
|
+
module Migrations
|
|
13
|
+
# `from`/`to` are majors. `changes` each have a title and, for readability
|
|
14
|
+
# at the terminal, pre-wrapped `before` / `now` / `fix` prose.
|
|
15
|
+
ENTRIES = [
|
|
16
|
+
{
|
|
17
|
+
from: 0,
|
|
18
|
+
to: 1,
|
|
19
|
+
changes: [
|
|
20
|
+
{
|
|
21
|
+
title: 'Profiles select windows additively instead of subtracting them',
|
|
22
|
+
before: 'Every window in `windows:` ran. A profile overrode some of ' \
|
|
23
|
+
'them and dropped others with `~`, so `staging: {}` meant ' \
|
|
24
|
+
'"run everything, unchanged".',
|
|
25
|
+
now: '`windows:` is a catalog and nothing in it runs on its own. ' \
|
|
26
|
+
'Each profile lists the windows it runs, and several profiles ' \
|
|
27
|
+
'can be selected at once (`-p a,b`) — the live set is their ' \
|
|
28
|
+
'union. A window no selected profile lists stays down.',
|
|
29
|
+
fix: 'In every profile, list the windows it should run: `name: {}` ' \
|
|
30
|
+
'to include one as declared in the catalog, or a non-empty ' \
|
|
31
|
+
'block to include it with overrides. A profile that listed ' \
|
|
32
|
+
'nothing (`staging: {}`) must now name every window it wants, ' \
|
|
33
|
+
'and a profile that dropped windows with `name: ~` should ' \
|
|
34
|
+
'instead list the ones it keeps. `~` still parses, but it now ' \
|
|
35
|
+
'means "veto": it collides if another selected profile ' \
|
|
36
|
+
'includes that window.'
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
title: '`default_profile` may name several profiles',
|
|
40
|
+
before: 'A single profile name.',
|
|
41
|
+
now: 'A name or a list of names, selected together when ' \
|
|
42
|
+
'`--profile` is omitted.',
|
|
43
|
+
fix: 'Nothing to change — a single name still works.'
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
title: '`mxup_version` selects which format a config is read as',
|
|
47
|
+
before: 'The key did not exist.',
|
|
48
|
+
now: 'A config with no `mxup_version` is read as the 0.x format — ' \
|
|
49
|
+
'profiles included — so it goes on running under mxup 1.x ' \
|
|
50
|
+
'exactly as it did under 0.3.1. The key is what opts a ' \
|
|
51
|
+
'config in to the format described above.',
|
|
52
|
+
fix: 'Nothing, to keep a 0.x config working. Set ' \
|
|
53
|
+
"`mxup_version: '1'` once you have migrated its profiles; " \
|
|
54
|
+
'that is also what unlocks selecting several at once.'
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
].freeze
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
def render(out: $stdout)
|
|
62
|
+
out.puts "mxup #{VERSION} — config format changes by major version."
|
|
63
|
+
out.puts
|
|
64
|
+
|
|
65
|
+
ENTRIES.each { |entry| render_entry(entry, out) }
|
|
66
|
+
|
|
67
|
+
out.puts '`mxup_version` names the generation a config is written for, and'
|
|
68
|
+
out.puts 'mxup reads every generation up to its own — so a config with no'
|
|
69
|
+
out.puts '`mxup_version` keeps running as 0.x. Set it to the major you have'
|
|
70
|
+
out.puts 'migrated to.'
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def render_entry(entry, out)
|
|
76
|
+
out.puts "#{entry[:from]}.x -> #{entry[:to]}.x"
|
|
77
|
+
out.puts
|
|
78
|
+
|
|
79
|
+
entry[:changes].each_with_index do |change, i|
|
|
80
|
+
out.puts " #{i + 1}. #{change[:title]}"
|
|
81
|
+
%i[before now fix].each do |key|
|
|
82
|
+
out.puts wrap(change[key], label: "#{key.to_s.capitalize}:")
|
|
83
|
+
end
|
|
84
|
+
out.puts
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Hanging-indent wrap so each paragraph's continuation lines line up under
|
|
89
|
+
# its text rather than under the label.
|
|
90
|
+
def wrap(text, label:, width: 76, indent: 5)
|
|
91
|
+
prefix = "#{' ' * indent}#{label.ljust(8)}"
|
|
92
|
+
continue = ' ' * prefix.length
|
|
93
|
+
lines = []
|
|
94
|
+
|
|
95
|
+
text.split(' ').each do |word|
|
|
96
|
+
if lines.empty?
|
|
97
|
+
lines << prefix + word
|
|
98
|
+
elsif (lines.last.length + 1 + word.length) > width
|
|
99
|
+
lines << continue + word
|
|
100
|
+
else
|
|
101
|
+
lines[-1] = "#{lines.last} #{word}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
lines.join("\n")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/mxup/reconciler.rb
CHANGED
|
@@ -64,7 +64,7 @@ module Mxup
|
|
|
64
64
|
order.drop(1).each { |entry| create_entry(entry) }
|
|
65
65
|
|
|
66
66
|
Tmux.set_environment(@session, 'MXUP_LAYOUT', layout) if layout
|
|
67
|
-
Tmux.set_environment(@session, 'MXUP_PROFILE', @config.
|
|
67
|
+
Tmux.set_environment(@session, 'MXUP_PROFILE', @config.profiles_label) if @config.profiles.any?
|
|
68
68
|
out.puts "Session #{@session} is up (#{@config.windows.size} windows)."
|
|
69
69
|
end
|
|
70
70
|
|
|
@@ -113,7 +113,9 @@ module Mxup
|
|
|
113
113
|
|
|
114
114
|
@layout_manager.reorder(layout) unless @dry_run
|
|
115
115
|
Tmux.set_environment(@session, 'MXUP_LAYOUT', layout) if layout && !@dry_run
|
|
116
|
-
|
|
116
|
+
if @config.profiles.any? && !@dry_run
|
|
117
|
+
Tmux.set_environment(@session, 'MXUP_PROFILE', @config.profiles_label)
|
|
118
|
+
end
|
|
117
119
|
out.puts 'Reconciliation complete.'
|
|
118
120
|
end
|
|
119
121
|
|