mark-twin 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d54022c14550637480eed5ecea2c7cf5b391d75724559c5c25cdac8cef134526
4
- data.tar.gz: c5fc90db5a080109b60f330f591cb62055412ca3cf59f7d68d89e156b6d17f85
3
+ metadata.gz: 9e643b14b2fe0dd153539ff16d10a0950f3c478d327620dec1b9b04d7af4f340
4
+ data.tar.gz: 4eb24c0280ee5f1085024c1357a6d7b8dd0b7f16198e3cce8b4c9ce5849705b5
5
5
  SHA512:
6
- metadata.gz: 4511a3d7293d69be4cabd081c772ac252a0123f4207cabd007da52186b3a4cc0f7edff487b8530484ab083ecab0ced150060c3912c4605021689ad3fc9e5caf1
7
- data.tar.gz: c4968f1c11bebaa8b7b883f9271e5b55495a8bde93f6557d8a5e2842bac5c263c8e859c0a74e35ee4b119f3d75056170a0016d4f3516804c1926d136709a62f8
6
+ metadata.gz: 7d5567dc0637bc767f7ddff5716a2e5031902e787958e1ecd69f54931e737b2946d82c999637d6610d732e5063898c3b717b49148f8e495af328ea2f0b716d03
7
+ data.tar.gz: 94df8bd72ea5548e668b1173e17b9e9bf54efc1fbca4576c109ed64740794decb94eb23fab78c50ed2b0654bc2160bb1a4be85c74a2f9281bd2a178e0f33b270
data/ARCHITECTURE.md CHANGED
@@ -53,7 +53,7 @@ test/test_pure.rb
53
53
 
54
54
  ```
55
55
  program, path, description, active, excludes, owned, label, source, target, cmd,
56
- delete, render, render_outdated, target_path_field, sync_file,
56
+ delete, render, render_outdated, target_path_field, sync_file, verify,
57
57
  source_exists, target_exists, source_mtime, target_mtime, conflict,
58
58
  directory, content_equal, drift
59
59
  ```
@@ -68,7 +68,10 @@ content-verified when set. Directory jobs get no mtime judgment at all: a
68
68
  directory's mtime moves on every sync and on every excluded file, so
69
69
  `Job#status` reports `unverified` until `twin status` fills `drift` by asking
70
70
  rsync (`Conflict.drift` — paired dry-runs, itemize classification, checksums
71
- for timestamp-only candidates).
71
+ for timestamp-only candidates). `Verify: false` opts a job out of every
72
+ content round — scanner md5, drift dry-runs, pre-sync conflict detection —
73
+ for entries where the walk itself is the cost; its status stays mtime-based
74
+ (files) or `unverified` (directories).
72
75
 
73
76
  `Job#status` → one of `disabled / unreachable / both_missing / missing_source /
74
77
  missing_target / target_newer / in_sync / source_newer / unverified`. Render
@@ -164,16 +167,28 @@ rationale.
164
167
  Two stages:
165
168
 
166
169
  1. **Stage 1 — program picker.** Multi-line NUL-separated entries (`--read0`).
167
- Each entry has a header (icon, program name, job count, sync-file) and
170
+ Each entry has a header (icon, program name, job count, sync-files) and
168
171
  indented body lines (one per job). No preview. Single-select. ESC exits.
172
+ Same-named programs (case-insensitive) from different sync-files are merged
173
+ into one entry, and a trailing bracket group joins by convention:
174
+ `livesync [agent]` merges under `livesync`. A picker-only view
175
+ (`Picker.merge_programs`); the data model, `status`, `sync -p` and JSON
176
+ keep the per-file programs. Jobs stay grouped per file in document order,
177
+ so a file's closing `Cmd` block still fires after its own paths.
169
178
  2. **Stage 2 — path multi-picker.** Tab-delimited rows (`id\tdisplay`),
170
- `--with-nth=2` hides the `id`. Multi-select via Tab. Preview pane shows
171
- the *compact* view (frontmatter + intro + the heading section containing
172
- the YAML block for the highlighted path), rendered via
179
+ `--with-nth=2` hides the `id`. Multi-select via Tab. On open, directory
180
+ jobs get their drift verified (`Conflict.fill_drift` affordable for one
181
+ program, unlike for the whole of stage 1). A merged program's rows are
182
+ sectioned per sync-file by dim header rows whose id is `-`; they map to no
183
+ job, so toggling one is inert. Preview pane shows the *compact* view
184
+ (frontmatter + intro + the heading section containing the YAML block for
185
+ the highlighted path) from the job's own sync-file, rendered via
173
186
  `apex --plugins -t terminal256`. ESC returns to Stage 1.
174
187
 
175
188
  Compact previews are pre-rendered to per-job tempfiles before fzf launches.
176
- An `awk` lookup maps `{1}` (the id) → tempfile path.
189
+ An `awk` lookup maps `{1}` (the id) → tempfile path. The Tempfile objects
190
+ stay referenced while fzf runs — a GC'd Tempfile unlinks its file under the
191
+ running preview.
177
192
 
178
193
  ## CLI
179
194
 
data/README.md CHANGED
@@ -58,7 +58,10 @@ on both sides since the last sync, and paths that differ per host.
58
58
  ## Screenshots
59
59
 
60
60
  Stage 1 — program picker. One row per program, color-coded status, indented
61
- paths underneath:
61
+ paths underneath. A name that appears in several sync-files
62
+ (the app in one, its config in another) is one entry, and a trailing bracket
63
+ group ties variants to their base name — `livesync [agent]` lists under
64
+ `livesync`. Stage 2 sections the merged paths per origin:
62
65
 
63
66
  ![Stage 1 — program picker](https://raw.githubusercontent.com/rhsev/mark-twin/main/docs/stage_1.png)
64
67
 
@@ -213,7 +216,7 @@ rather than as an error.
213
216
 
214
217
  | Field | Where | Meaning |
215
218
  |---|---|---|
216
- | `Program` | block | Group name; blocks sharing it sync together |
219
+ | `Program` | block | Group name; blocks sharing it sync together. A trailing `[…]` lists under the base name in the picker |
217
220
  | `Path` | block | Path relative to `Source` (file or directory) |
218
221
  | `Source` | either | Absolute base path on this machine |
219
222
  | `Target` | either | Absolute base path, or `user@host:/path` for ssh |
@@ -226,6 +229,7 @@ rather than as an error.
226
229
  | `Delete` | block | `true` mirrors deletions, with backups |
227
230
  | `Cmd` | block | Shell command, run only when bytes actually moved |
228
231
  | `Render` | block | `true` substitutes `{{tokens}}` instead of copying |
232
+ | `Verify` | block | `false` skips content verification — for entries too big (see below) |
229
233
 
230
234
  ### Exclude or Own?
231
235
 
@@ -376,9 +380,19 @@ Two properties make this bearable day to day:
376
380
 
377
381
  `twin status` gives the same verdict without syncing: it runs the dry-runs per
378
382
  entry and reports what would flow, what merely differs in timestamp, and what
379
- changed on the target. Only the picker stays mtime-blind for directory entries
380
- — it marks them `∘` (unverified) rather than guessing, because the dry-runs
381
- would make it slow to open.
383
+ changed on the target. The picker's first stage stays mtime-blind for
384
+ directory entries — it marks them `∘` (unverified) rather than guessing,
385
+ because the dry-runs would make it slow to open. Entering a program runs them
386
+ for just that program, so its directory rows show a real verdict.
387
+
388
+ One escape hatch: `Verify: false` opts an entry out of every content round —
389
+ the md5 checks, the status dry-runs, the picker's verification, the pre-sync
390
+ conflict listing. It exists for entries where the walk itself is the cost: a
391
+ `node_modules` tree on an SMB mount stats tens of thousands of files for one
392
+ verdict. Such an entry stays `∘`/mtime-based, and the picker's second stage
393
+ says so in its header rather than letting the `∘` pass as pending; a sync
394
+ still leaves newer target files alone (`--update` holds), they just aren't
395
+ itemised first.
382
396
 
383
397
  ## Automation
384
398
 
@@ -443,7 +457,7 @@ it differs from the current target, so a `Cmd` hook fires only on a real change.
443
457
  ## LiveSync LaunchAgent
444
458
 
445
459
  ```yaml
446
- Program: livesync-agent
460
+ Program: livesync [agent]
447
461
  Source: "{{src.home}}/Automation/launchd"
448
462
  Path: com.ralf.livesync.plist
449
463
  Target: "{{dst.mount}}"
data/lib/twin/cli.rb CHANGED
@@ -93,7 +93,7 @@ module Twin
93
93
  exit 1
94
94
  end
95
95
 
96
- programs = Scanner.load_programs(cfg, file: file, show_all: false)
96
+ programs = Picker.merge_programs(Scanner.load_programs(cfg, file: file, show_all: false))
97
97
  if programs.empty?
98
98
  warn "no active programs found#{" in #{file}" if file}"
99
99
  return
@@ -125,7 +125,7 @@ module Twin
125
125
  break if $stdin.gets&.strip == "q"
126
126
 
127
127
  # reload so status reflects what was just synced, stay on this program
128
- programs = Scanner.load_programs(cfg, file: file, show_all: false)
128
+ programs = Picker.merge_programs(Scanner.load_programs(cfg, file: file, show_all: false))
129
129
  selected_key = [program.name, program.sync_file]
130
130
  end
131
131
  end
@@ -168,7 +168,11 @@ module Twin
168
168
  puts " #{j.path}#{conflict}"
169
169
  if j.directory
170
170
  # Directory mtimes prove nothing — the drift verdict replaces them.
171
- puts " #{j.drift ? drift_summary(j.drift) : "(not checked — target unavailable)"}"
171
+ note = if j.drift then drift_summary(j.drift)
172
+ elsif j.verify == false then "(not checked — Verify: false)"
173
+ else "(not checked — target unavailable)"
174
+ end
175
+ puts " #{note}"
172
176
  else
173
177
  src = j.source_exists ? j.source_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
174
178
  tgt = j.target_exists ? j.target_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
@@ -183,30 +187,9 @@ module Twin
183
187
  end
184
188
  end
185
189
 
186
- # Ask rsync what a sync of each directory job would actually do — the
187
- # dry-runs are subprocess I/O, so a few run in parallel. File jobs are
188
- # already content-checked by the scanner and need no second look.
190
+ # Ask rsync what a sync of each directory job would actually do.
189
191
  def verify_drift(cfg, programs)
190
- jobs = programs.flat_map(&:jobs).select do |j|
191
- j.directory && j.active == 1 && !j.target_unreachable &&
192
- j.source_exists && j.target_exists
193
- end
194
- return if jobs.empty?
195
-
196
- queue = Queue.new
197
- jobs.each { |j| queue << j }
198
- Array.new([4, jobs.size].min) do
199
- Thread.new do
200
- loop do
201
- j = begin
202
- queue.pop(true)
203
- rescue ThreadError
204
- break
205
- end
206
- j.drift = Twin::Conflict.drift(cfg, j)
207
- end
208
- end
209
- end.each(&:join)
192
+ Twin::Conflict.fill_drift(cfg, programs.flat_map(&:jobs))
210
193
  end
211
194
 
212
195
  def drift_summary(d)
@@ -314,7 +297,10 @@ module Twin
314
297
  return true if force
315
298
  return false if skip_conflicts || dry_run
316
299
 
317
- conflicts = jobs.flat_map { |j| Twin::Conflict.detect(cfg, j) }
300
+ # Verify: false jobs skip the detection round (too big to walk); rsync's
301
+ # --update still keeps newer target files, they just aren't listed here.
302
+ conflicts = jobs.reject { |j| j.verify == false }
303
+ .flat_map { |j| Twin::Conflict.detect(cfg, j) }
318
304
  return false if conflicts.empty?
319
305
 
320
306
  report_conflicts(conflicts)
data/lib/twin/conflict.rb CHANGED
@@ -79,6 +79,38 @@ module Twin
79
79
  drift(cfg, job)&.conflicts || []
80
80
  end
81
81
 
82
+ # Directory jobs whose drift is still unknown and answerable — both sides
83
+ # present, target reachable, not opted out via Verify: false. File jobs
84
+ # are content-checked by the scanner and need no second look.
85
+ def drift_candidates(jobs)
86
+ jobs.select do |j|
87
+ j.drift.nil? && j.verify != false && j.directory && j.active == 1 &&
88
+ !j.target_unreachable && j.source_exists && j.target_exists
89
+ end
90
+ end
91
+
92
+ # Fill `drift` on every candidate by asking rsync — the dry-runs are
93
+ # subprocess I/O, so a few run in parallel.
94
+ def fill_drift(cfg, jobs)
95
+ jobs = drift_candidates(jobs)
96
+ return if jobs.empty?
97
+
98
+ queue = Queue.new
99
+ jobs.each { |j| queue << j }
100
+ Array.new([4, jobs.size].min) do
101
+ Thread.new do
102
+ loop do
103
+ j = begin
104
+ queue.pop(true)
105
+ rescue ThreadError
106
+ break
107
+ end
108
+ j.drift = drift(cfg, j)
109
+ end
110
+ end
111
+ end.each(&:join)
112
+ end
113
+
82
114
  # Pure assembly of a Drift from parsed entries. A file the normal run
83
115
  # would also transfer flows source→target as intended; one only the
84
116
  # forced run would touch is being held back by --update — the target owns
data/lib/twin/picker.rb CHANGED
@@ -2,6 +2,7 @@ require "open3"
2
2
  require "tempfile"
3
3
 
4
4
  require_relative "preview"
5
+ require_relative "conflict"
5
6
 
6
7
  module Twin
7
8
  # Two-step fzf picker:
@@ -14,9 +15,10 @@ module Twin
14
15
  source_newer: "→",
15
16
  target_newer: "←",
16
17
  in_sync: "✓",
17
- # Directory jobs in the picker: their mtimes prove nothing, and the
18
- # dry-run that would prove something is too slow for a picker to open
19
- # with. No claim instead of a wrong one — `twin status` has the verdict.
18
+ # Directory jobs in stage 1: their mtimes prove nothing, and the
19
+ # dry-runs that would prove something are too slow for the full picker.
20
+ # No claim instead of a wrong one — stage 2 verifies its one program
21
+ # on open, and `twin status` has the full verdict.
20
22
  unverified: "∘",
21
23
  missing_target: "!",
22
24
  missing_source: "!",
@@ -49,6 +51,29 @@ module Twin
49
51
 
50
52
  # ── Stufe 1: program picker ───────────────────────────────────────────────
51
53
 
54
+ # A trailing bracket group ties a program to its base name by convention:
55
+ # "livesync [agent]" lists under "livesync". Only the suffix form counts —
56
+ # brackets elsewhere are just a name.
57
+ BRACKET_SUFFIX = /\s*\[[^\]]*\]\z/
58
+
59
+ def base_name(name) = name.sub(BRACKET_SUFFIX, "").strip
60
+
61
+ # One stage-1 entry per base name (case-insensitive), even when it appears
62
+ # in several sync-files or as bracketed variants — "everything of
63
+ # fileview" is one decision. The list is sorted by name; document order
64
+ # only orders the *jobs*, program order carries no meaning. Purely a
65
+ # picker view: the data model, `status`, `sync -p` and JSON keep the
66
+ # per-file programs. Jobs stay grouped per file in document order, so a
67
+ # file's closing Cmd block still fires after its own paths.
68
+ def merge_programs(programs)
69
+ programs.group_by { |p| base_name(p.name).downcase }.values.map do |group|
70
+ next group.first if group.size == 1
71
+ name = group.find { |p| !p.name.match?(BRACKET_SUFFIX) }&.name ||
72
+ base_name(group.first.name)
73
+ Program.new(name: name, jobs: group.flat_map(&:jobs))
74
+ end.sort_by { |p| p.name.downcase }
75
+ end
76
+
52
77
  # Multi-line entries (NUL-separated). Header line per program, indented
53
78
  # body lines per job. Returns the selected Program or nil.
54
79
  def pick_program(programs)
@@ -90,14 +115,45 @@ module Twin
90
115
  jobs = program.jobs
91
116
  return [] if jobs.empty?
92
117
 
118
+ # Stage 2 covers one program, so the dry-runs that are too slow for the
119
+ # whole picker are affordable here — directory rows get a real verdict
120
+ # instead of `∘`. Already-verified jobs are skipped on re-entry.
121
+ if Conflict.drift_candidates(jobs).any?
122
+ $stderr.print dim("verifying directories …")
123
+ Conflict.fill_drift(cfg, jobs)
124
+ $stderr.print "\r\e[K"
125
+ end
126
+
93
127
  path_width = jobs.map { |j| j.path.length }.max
94
128
  tempfiles = write_compact_previews(program, jobs)
95
129
  preview_cmd, mapfile = build_apex_preview_cmd(tempfiles, cfg)
96
130
 
97
- rows = jobs.each_with_index.map do |j, i|
98
- icon = STATUS_ICONS[j.status] || "?"
99
- line = "#{icon} #{j.path.ljust(path_width)} #{job_delta(j)}"
100
- "#{i}\t#{colorize(j.status, line)}"
131
+ # A merged program spans sync-files (and bracketed variants); a dim
132
+ # section row marks each block, named by its file — plus the original
133
+ # program name when it differs from the merged one. Section rows carry
134
+ # "-" instead of an index — fzf can't make them unselectable, but they
135
+ # map to no job, so toggling one is inert.
136
+ section = ->(j) { [j.program, j.sync_file] }
137
+ multi = jobs.map(&section).uniq.size > 1
138
+ rows = []
139
+ prev = nil
140
+ jobs.each_with_index do |j, i|
141
+ if multi && section.(j) != prev
142
+ file = File.basename(j.sync_file.to_s)
143
+ label = j.program == program.name ? file : "#{j.program} · #{file}"
144
+ rows << "-\t#{dim("[#{label}]")}"
145
+ prev = section.(j)
146
+ end
147
+ icon = STATUS_ICONS[j.status] || "?"
148
+ line = "#{icon} #{j.path.ljust(path_width)} #{job_delta(j)}"
149
+ rows << "#{i}\t#{colorize(j.status, line)}"
150
+ end
151
+
152
+ header = "#{program.name} — Tab toggles, Enter confirms"
153
+ unverified = jobs.select { |j| j.verify == false }
154
+ unless unverified.empty?
155
+ header += "\n\e[33m⚠ content checks off (Verify: false): " \
156
+ "#{unverified.map(&:path).join(", ")}\e[0m"
101
157
  end
102
158
 
103
159
  fzf = [
@@ -105,7 +161,7 @@ module Twin
105
161
  "--multi", "--ansi",
106
162
  "--delimiter=\t", "--with-nth=2",
107
163
  "--prompt=#{program.name} > ",
108
- "--header=#{program.name} — Tab toggles, Enter confirms",
164
+ "--header=#{header}",
109
165
  "--preview=#{preview_cmd}",
110
166
  "--preview-window=right:60%:wrap",
111
167
  "--height=100%", "--reverse",
@@ -113,45 +169,55 @@ module Twin
113
169
  "--color=bg+:-1,hl+:reverse",
114
170
  ]
115
171
 
116
- output, status = Open3.capture2(SH_ENV, *fzf, stdin_data: rows.join("\n"))
117
- return :back if status.exitstatus == 130 # ESC / Ctrl-C
118
- return [] unless status.success?
119
- return [] if output.strip.empty?
172
+ loop do
173
+ output, status = Open3.capture2(SH_ENV, *fzf, stdin_data: rows.join("\n"))
174
+ return :back if status.exitstatus == 130 # ESC / Ctrl-C
175
+ return [] unless status.success?
176
+ return [] if output.strip.empty?
120
177
 
121
- output.lines.filter_map do |line|
122
- idx = line.split("\t", 2).first&.to_i
123
- jobs[idx] if idx
178
+ selected = output.lines.filter_map do |line|
179
+ idx = line.split("\t", 2).first
180
+ jobs[idx.to_i] if idx&.match?(/\A\d+\z/)
181
+ end
182
+ return selected unless selected.empty?
183
+ # Enter landed on a section header (or only headers were toggled):
184
+ # nothing real was accepted — reopen rather than read it as "exit".
124
185
  end
125
186
  ensure
126
- tempfiles&.each_value { |path| File.unlink(path) rescue nil }
127
- File.unlink(mapfile) rescue nil if mapfile
187
+ tempfiles&.each_value { |f| f.close! rescue nil }
188
+ mapfile&.close! rescue nil
128
189
  end
129
190
 
130
- # Write per-job compact-preview markdown to tempfiles. Returns {idx => path}.
191
+ # Write per-job compact-preview markdown to tempfiles. Returns
192
+ # {idx => Tempfile}. The Tempfile objects (not just their paths) must stay
193
+ # referenced while fzf runs: a GC'd Tempfile unlinks its file, and the
194
+ # preview command would find nothing to render. Each job's excerpt comes
195
+ # from its own sync-file — a merged program spans several.
131
196
  def write_compact_previews(program, jobs)
132
197
  result = {}
133
198
  jobs.each_with_index do |job, i|
134
- compact = Preview.extract_compact(program.sync_file, job.path)
199
+ compact = Preview.extract_compact(job.sync_file, job.path)
135
200
  f = Tempfile.new(["twin-#{i}-", ".md"])
136
201
  f.write(compact)
137
202
  f.close
138
- result[i] = f.path
203
+ result[i] = f
139
204
  end
140
205
  result
141
206
  end
142
207
 
143
- # Returns [preview_cmd, mapfile_path]; the caller unlinks the mapfile.
208
+ # Returns [preview_cmd, mapfile]; the caller holds the Tempfile until fzf
209
+ # is done (GC would unlink it) and closes it afterwards.
144
210
  def build_apex_preview_cmd(tempfiles, cfg)
145
211
  # Map idx → file via a small TSV, awk picks the right one for {1}.
146
212
  mapfile = Tempfile.new(["twin-map-", ".tsv"])
147
- tempfiles.each { |i, path| mapfile.puts("#{i}\t#{path}") }
213
+ tempfiles.each { |i, f| mapfile.puts("#{i}\t#{f.path}") }
148
214
  mapfile.close
149
215
 
150
216
  render_cmd = pick_renderer(cfg)
151
217
 
152
218
  cmd = %(F=$(awk -v id={1} -F'\\t' '$1==id {print $2}' #{mapfile.path}); ) +
153
219
  %([ -n "$F" ] && #{render_cmd})
154
- [cmd, mapfile.path]
220
+ [cmd, mapfile]
155
221
  end
156
222
 
157
223
  # Pick the first available markdown renderer.
@@ -182,11 +248,11 @@ module Twin
182
248
 
183
249
  def render_program_entry(program, name_width, path_width)
184
250
  icon = colorize(program.status, STATUS_ICONS[program.status] || "?")
185
- sync_file = File.basename(program.sync_file.to_s)
251
+ files = program.jobs.map { |j| File.basename(j.sync_file.to_s) }.uniq.join(" · ")
186
252
  count = program.active_jobs.size
187
253
  total = program.jobs.size
188
254
  header = "#{icon} #{bold(program.name.ljust(name_width))} " \
189
- "#{dim("(#{count}/#{total})")} #{dim("[#{sync_file}]")}"
255
+ "#{dim("(#{count}/#{total})")} #{dim("[#{files}]")}"
190
256
 
191
257
  body = program.jobs.map do |j|
192
258
  icon = STATUS_ICONS[j.status] || "?"
data/lib/twin/scanner.rb CHANGED
@@ -10,7 +10,7 @@ module Twin
10
10
  :program, :path, :description, :active, :excludes, :owned, :label,
11
11
  :source, :target, :cmd, :delete, :render, :render_outdated, :target_path_field, :sync_file,
12
12
  :source_exists, :target_exists, :source_mtime, :target_mtime, :conflict,
13
- :target_unreachable, :directory, :content_equal, :drift,
13
+ :target_unreachable, :directory, :content_equal, :drift, :verify,
14
14
  keyword_init: true,
15
15
  ) do
16
16
  def source_path = File.join(source, path)
@@ -129,7 +129,8 @@ module Twin
129
129
  # content clears the conflict — the timestamps merely disagree.
130
130
  def verify_remote_file_content(host, host_jobs)
131
131
  candidates = host_jobs.select do |j|
132
- !j.directory && !j.render && j.source_exists && j.target_exists &&
132
+ j.verify != false && !j.directory && !j.render &&
133
+ j.source_exists && j.target_exists &&
133
134
  j.source_mtime && j.target_mtime && (j.target_mtime - j.source_mtime).abs >= 60
134
135
  end
135
136
  return if candidates.empty?
@@ -183,6 +184,11 @@ module Twin
183
184
  return nil if path.empty? || source.empty? || target.empty?
184
185
 
185
186
  render = r["Render"] == true
187
+ # Verify: false — no content verification for this entry, ever: too big
188
+ # or too remote for md5/dry-run rounds (a node_modules tree over SMB).
189
+ # Status falls back to mtime for files and stays ∘ for directories; the
190
+ # pre-sync conflict check skips it (--update still protects the target).
191
+ verify = r["Verify"] != false
186
192
  excludes = split_list(r["Exclude"])
187
193
  # Own: paths inside the sync scope that the TARGET owns — machine-specific
188
194
  # config the source must never clobber. Same rsync effect as Exclude, but
@@ -214,7 +220,7 @@ module Twin
214
220
  # (a `cat >` copy before the first twin run). Check before judging;
215
221
  # a directory's own mtime is judged not at all (see Job#status).
216
222
  content_equal = nil
217
- if !render && !remote && !directory && src_exists && tgt_exists &&
223
+ if verify && !render && !remote && !directory && src_exists && tgt_exists &&
218
224
  src_mtime && tgt_mtime && (tgt_mtime - src_mtime).abs >= 60
219
225
  content_equal = Twin::Conflict.same_content?(src_full, tgt_full)
220
226
  end
@@ -248,6 +254,7 @@ module Twin
248
254
  target_unreachable: false,
249
255
  directory: directory,
250
256
  content_equal: content_equal,
257
+ verify: verify,
251
258
  )
252
259
  end
253
260
 
data/lib/twin/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Twin
2
- VERSION = "0.5.0"
2
+ VERSION = "0.6.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mark-twin
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ralf Hülsmann