mark-twin 0.4.3 → 0.5.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/ARCHITECTURE.md +14 -6
- data/README.md +26 -10
- data/lib/twin/cli.rb +100 -6
- data/lib/twin/conflict.rb +125 -58
- data/lib/twin/picker.rb +13 -4
- data/lib/twin/remote.rb +100 -5
- data/lib/twin/scanner.rb +56 -4
- data/lib/twin/sync.rb +3 -0
- data/lib/twin/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d54022c14550637480eed5ecea2c7cf5b391d75724559c5c25cdac8cef134526
|
|
4
|
+
data.tar.gz: c5fc90db5a080109b60f330f591cb62055412ca3cf59f7d68d89e156b6d17f85
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4511a3d7293d69be4cabd081c772ac252a0123f4207cabd007da52186b3a4cc0f7edff487b8530484ab083ecab0ced150060c3912c4605021689ad3fc9e5caf1
|
|
7
|
+
data.tar.gz: c4968f1c11bebaa8b7b883f9271e5b55495a8bde93f6557d8a5e2842bac5c263c8e859c0a74e35ee4b119f3d75056170a0016d4f3516804c1926d136709a62f8
|
data/ARCHITECTURE.md
CHANGED
|
@@ -54,17 +54,25 @@ test/test_pure.rb
|
|
|
54
54
|
```
|
|
55
55
|
program, path, description, active, excludes, owned, label, source, target, cmd,
|
|
56
56
|
delete, render, render_outdated, target_path_field, sync_file,
|
|
57
|
-
source_exists, target_exists, source_mtime, target_mtime, conflict
|
|
57
|
+
source_exists, target_exists, source_mtime, target_mtime, conflict,
|
|
58
|
+
directory, content_equal, drift
|
|
58
59
|
```
|
|
59
60
|
|
|
60
61
|
`excludes` and `owned` both become `--exclude` (via `Job#all_excludes`); they
|
|
61
|
-
are kept apart so `status` can report intent.
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
are kept apart so `status` can report intent.
|
|
63
|
+
|
|
64
|
+
mtime is never the verdict, only a pre-filter for file jobs — and even there
|
|
65
|
+
identical bytes under a drifted timestamp clear it (`content_equal`; remote
|
|
66
|
+
targets get one batched md5 round per host). `conflict` is therefore
|
|
67
|
+
content-verified when set. Directory jobs get no mtime judgment at all: a
|
|
68
|
+
directory's mtime moves on every sync and on every excluded file, so
|
|
69
|
+
`Job#status` reports `unverified` until `twin status` fills `drift` by asking
|
|
70
|
+
rsync (`Conflict.drift` — paired dry-runs, itemize classification, checksums
|
|
71
|
+
for timestamp-only candidates).
|
|
64
72
|
|
|
65
73
|
`Job#status` → one of `disabled / unreachable / both_missing / missing_source /
|
|
66
|
-
missing_target / target_newer / in_sync / source_newer`. Render
|
|
67
|
-
(`render_outdated`), not mtime
|
|
74
|
+
missing_target / target_newer / in_sync / source_newer / unverified`. Render
|
|
75
|
+
jobs derive status from content (`render_outdated`), not mtime.
|
|
68
76
|
`Job#target_path` joins `target` with `target_path_field || path`.
|
|
69
77
|
|
|
70
78
|
**Program** — group of Jobs sharing a `program` name:
|
data/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
7
|
Sync configuration between Macs (or to any host you can reach over ssh) from
|
|
8
|
-
self-documenting Markdown files.
|
|
8
|
+
self-documenting Markdown files. Designed for informed, interactive syncing.
|
|
9
9
|
|
|
10
10
|
A sync-file is a normal Markdown document. The prose is for you, or for an AI
|
|
11
11
|
assistant. twin only uses the fenced YAML blocks:
|
|
@@ -30,8 +30,11 @@ Own: conf.d/local.fish
|
|
|
30
30
|
```
|
|
31
31
|
````
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
twin runs when you ask, not in the background. It syncs what is unambiguous;
|
|
34
|
+
where the target has changes of its own, it stops and asks, diff in hand
|
|
35
|
+
(verified against content, never guessed from timestamps). At its core it's
|
|
36
|
+
just `rsync`, but a year later the file still tells you *why* you did it that
|
|
37
|
+
way.
|
|
35
38
|
|
|
36
39
|
That is the whole idea, and for most entries it stays as small as the excerpt
|
|
37
40
|
above. The rest of this README is long because twin also covers more complex
|
|
@@ -62,7 +65,7 @@ paths underneath:
|
|
|
62
65
|
Stage 2 — multi-select over the paths of one program. The right pane shows a
|
|
63
66
|
compact preview of the relevant sync-file section, rendered by apex:
|
|
64
67
|
|
|
65
|
-

|
|
66
69
|
|
|
67
70
|
## Install
|
|
68
71
|
|
|
@@ -144,13 +147,13 @@ twin # picker — all programs across all sync-files
|
|
|
144
147
|
twin home.md # picker — one sync-file in sync_dir (by name)
|
|
145
148
|
twin ./some/dir/ # picker — all sync-files in a directory
|
|
146
149
|
twin list # plain listing
|
|
147
|
-
twin status #
|
|
150
|
+
twin status # what a sync would change, content-verified
|
|
148
151
|
twin sync -p grubber # sync one program by name pattern
|
|
149
152
|
twin sync --file=repos # sync all programs from a sync-file
|
|
150
153
|
twin sync --dry-run # preview without writing
|
|
151
154
|
twin sync -v # rsync's full output instead of just the changes
|
|
152
155
|
twin log # recent journal entries (-n N, --json)
|
|
153
|
-
twin doctor # check tools, renderers,
|
|
156
|
+
twin doctor # check tools, renderers, targets, remote hosts
|
|
154
157
|
twin --version # which twin is actually running
|
|
155
158
|
twin --help # show usage
|
|
156
159
|
```
|
|
@@ -265,8 +268,18 @@ pruning occasionally. It applies to `Delete` jobs only.
|
|
|
265
268
|
## Mounted volume or ssh
|
|
266
269
|
|
|
267
270
|
Both are first-class. `twin status`, the picker, `Exclude`/`Own`, `Delete` and
|
|
268
|
-
`Cmd` behave identically; remote paths are stat'ed
|
|
269
|
-
|
|
271
|
+
`Cmd` behave identically; remote paths are stat'ed (and, where timestamps
|
|
272
|
+
disagree, checksummed via `md5`/`md5sum`) in batched ssh round-trips per host,
|
|
273
|
+
and an unreachable host shows as `?` instead of failing the scan.
|
|
274
|
+
|
|
275
|
+
The remote side needs `rsync` and `/bin/sh`; `stat` (or `date -r`) and
|
|
276
|
+
`md5`/`md5sum` improve status from there, and `twin doctor` probes a host for
|
|
277
|
+
all of them before the first sync fails halfway. No particular login shell is
|
|
278
|
+
required — twin drives the far side through `/bin/sh` explicitly. That matters
|
|
279
|
+
more than it sounds: until 0.4.5 the stat script went to whatever shell the
|
|
280
|
+
account uses, so a host with fish (or any other non-POSIX shell) reported
|
|
281
|
+
every path as unreachable. It looked exactly like a machine being switched
|
|
282
|
+
off, which is why it went unnoticed for a while.
|
|
270
283
|
|
|
271
284
|
Two differences are real:
|
|
272
285
|
|
|
@@ -361,8 +374,11 @@ Two properties make this bearable day to day:
|
|
|
361
374
|
directory's mtime untouched, and `rsync -a` equalises those anyway. Twin asks
|
|
362
375
|
rsync what it would actually transfer instead of guessing from a directory.
|
|
363
376
|
|
|
364
|
-
`twin status`
|
|
365
|
-
|
|
377
|
+
`twin status` gives the same verdict without syncing: it runs the dry-runs per
|
|
378
|
+
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.
|
|
366
382
|
|
|
367
383
|
## Automation
|
|
368
384
|
|
data/lib/twin/cli.rb
CHANGED
|
@@ -28,7 +28,7 @@ module Twin
|
|
|
28
28
|
[--force] [--skip-conflicts]
|
|
29
29
|
twin add <path> scaffold a new sync entry for a local path
|
|
30
30
|
twin log [-n N] [--json] recent journal entries (default 20)
|
|
31
|
-
twin doctor check tools, renderers,
|
|
31
|
+
twin doctor check tools, renderers, targets, remote hosts
|
|
32
32
|
twin --help show this message
|
|
33
33
|
twin --version show the running version
|
|
34
34
|
|
|
@@ -150,6 +150,7 @@ module Twin
|
|
|
150
150
|
def cmd_status(cfg, args)
|
|
151
151
|
opts = parse_filter_opts(args)
|
|
152
152
|
programs = Scanner.load_programs(cfg, **opts.slice(:file, :label, :show_all))
|
|
153
|
+
verify_drift(cfg, programs)
|
|
153
154
|
|
|
154
155
|
if opts[:json]
|
|
155
156
|
puts JSON.pretty_generate(programs.map { |p| program_to_hash(p) })
|
|
@@ -163,19 +164,68 @@ module Twin
|
|
|
163
164
|
name = tty ? Picker.bold(p.name) : p.name
|
|
164
165
|
puts "#{icon} #{name}"
|
|
165
166
|
p.jobs.each do |j|
|
|
166
|
-
src = j.source_exists ? j.source_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
|
|
167
|
-
tgt = j.target_exists ? j.target_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
|
|
168
|
-
tgt = "(unreachable)" if j.target_unreachable
|
|
169
167
|
conflict = j.conflict ? (tty ? " #{Picker.colorize(:target_newer, "!")}" : " !") : ""
|
|
170
168
|
puts " #{j.path}#{conflict}"
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
if j.directory
|
|
170
|
+
# Directory mtimes prove nothing — the drift verdict replaces them.
|
|
171
|
+
puts " #{j.drift ? drift_summary(j.drift) : "(not checked — target unavailable)"}"
|
|
172
|
+
else
|
|
173
|
+
src = j.source_exists ? j.source_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
|
|
174
|
+
tgt = j.target_exists ? j.target_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
|
|
175
|
+
tgt = "(unreachable)" if j.target_unreachable
|
|
176
|
+
puts " src #{src}"
|
|
177
|
+
puts " dst #{tgt}"
|
|
178
|
+
puts " content identical, timestamps differ" if j.content_equal
|
|
179
|
+
end
|
|
173
180
|
# Named, not hidden: these belong to the target on purpose.
|
|
174
181
|
puts " own #{j.owned.join(', ')}" unless j.owned.nil? || j.owned.empty?
|
|
175
182
|
end
|
|
176
183
|
end
|
|
177
184
|
end
|
|
178
185
|
|
|
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.
|
|
189
|
+
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)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def drift_summary(d)
|
|
213
|
+
if d.in_sync?
|
|
214
|
+
note = d.time_only.empty? ? "" : " (#{d.time_only.size} timestamp-only)"
|
|
215
|
+
return "in sync#{note}"
|
|
216
|
+
end
|
|
217
|
+
parts = []
|
|
218
|
+
parts << "#{d.pending.size} to sync: #{list_some(d.pending)}" unless d.pending.empty?
|
|
219
|
+
unless d.conflicts.empty?
|
|
220
|
+
parts << "#{d.conflicts.size} changed on target: #{list_some(d.conflicts.map(&:rel))} (twin sync will ask)"
|
|
221
|
+
end
|
|
222
|
+
parts.join("; ")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def list_some(rels, max = 3)
|
|
226
|
+
rels.take(max).join(", ") + (rels.size > max ? ", …" : "")
|
|
227
|
+
end
|
|
228
|
+
|
|
179
229
|
# ── sync ───────────────────────────────────────────────────────────────────
|
|
180
230
|
|
|
181
231
|
def cmd_sync(cfg, args)
|
|
@@ -423,11 +473,13 @@ module Twin
|
|
|
423
473
|
if targets.empty?
|
|
424
474
|
puts " (no programs loaded)"
|
|
425
475
|
else
|
|
476
|
+
reachable_hosts = []
|
|
426
477
|
targets.each do |tgt|
|
|
427
478
|
if Twin::Remote.remote?(tgt)
|
|
428
479
|
host, = Twin::Remote.split(tgt)
|
|
429
480
|
if Twin::Remote.reachable?(host)
|
|
430
481
|
puts " ✓ #{tgt} (ssh)"
|
|
482
|
+
reachable_hosts << host
|
|
431
483
|
else
|
|
432
484
|
puts " ✗ #{tgt} (ssh: #{host} not reachable)"
|
|
433
485
|
ok = false
|
|
@@ -439,6 +491,7 @@ module Twin
|
|
|
439
491
|
ok = false
|
|
440
492
|
end
|
|
441
493
|
end
|
|
494
|
+
ok = doctor_remote_tools(reachable_hosts.uniq) && ok
|
|
442
495
|
end
|
|
443
496
|
rescue => e
|
|
444
497
|
puts " ✗ #{e.message}"
|
|
@@ -453,6 +506,42 @@ module Twin
|
|
|
453
506
|
system("command -v #{name} > /dev/null 2>&1")
|
|
454
507
|
end
|
|
455
508
|
|
|
509
|
+
# Probe each reachable ssh host once for what the far side must provide.
|
|
510
|
+
# A missing rsync fails doctor — the first sync would die mid-run with a
|
|
511
|
+
# raw protocol error. Missing stat/date or md5/md5sum only degrade
|
|
512
|
+
# (unknown mtimes, conservative conflicts), so they warn and say so.
|
|
513
|
+
def doctor_remote_tools(hosts)
|
|
514
|
+
return true if hosts.empty?
|
|
515
|
+
ok = true
|
|
516
|
+
puts "\nRemote tools"
|
|
517
|
+
hosts.each do |host|
|
|
518
|
+
tools = Twin::Remote.preflight(host)
|
|
519
|
+
if tools.nil?
|
|
520
|
+
puts " ✗ #{host} (probe failed)"
|
|
521
|
+
ok = false
|
|
522
|
+
next
|
|
523
|
+
end
|
|
524
|
+
problems = []
|
|
525
|
+
unless tools["rsync"]
|
|
526
|
+
problems << "rsync missing — syncs will fail (OpenWrt: opkg install rsync)"
|
|
527
|
+
ok = false
|
|
528
|
+
end
|
|
529
|
+
unless tools["stat"] || tools["date"]
|
|
530
|
+
problems << "no stat or date — remote mtimes read as unknown"
|
|
531
|
+
end
|
|
532
|
+
unless tools["md5"] || tools["md5sum"]
|
|
533
|
+
problems << "no md5 or md5sum — timestamp-only files stay flagged as conflicts"
|
|
534
|
+
end
|
|
535
|
+
if problems.empty?
|
|
536
|
+
have = ["rsync", tools["stat"] ? "stat" : "date", tools["md5"] ? "md5" : "md5sum"]
|
|
537
|
+
puts " ✓ #{host} (#{have.join(', ')})"
|
|
538
|
+
else
|
|
539
|
+
puts " #{tools["rsync"] ? "⚠" : "✗"} #{host} #{problems.join('; ')}"
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
ok
|
|
543
|
+
end
|
|
544
|
+
|
|
456
545
|
# ── option parsing ─────────────────────────────────────────────────────────
|
|
457
546
|
|
|
458
547
|
def parse_filter_opts(args)
|
|
@@ -501,6 +590,11 @@ module Twin
|
|
|
501
590
|
h[:status] = j.status
|
|
502
591
|
h[:source_mtime] = j.source_mtime&.iso8601
|
|
503
592
|
h[:target_mtime] = j.target_mtime&.iso8601
|
|
593
|
+
h[:drift] = j.drift && {
|
|
594
|
+
pending: j.drift.pending,
|
|
595
|
+
time_only: j.drift.time_only,
|
|
596
|
+
conflicts: j.drift.conflicts.map(&:rel),
|
|
597
|
+
}
|
|
504
598
|
h
|
|
505
599
|
end
|
|
506
600
|
end
|
data/lib/twin/conflict.rb
CHANGED
|
@@ -1,40 +1,43 @@
|
|
|
1
1
|
require "digest"
|
|
2
|
+
require "set"
|
|
2
3
|
|
|
3
4
|
require_relative "remote"
|
|
4
5
|
|
|
5
6
|
module Twin
|
|
6
|
-
#
|
|
7
|
-
#
|
|
7
|
+
# What would a sync actually change? mtimes cannot answer that — a
|
|
8
|
+
# directory's mtime records the last entry added or removed (after a sync:
|
|
9
|
+
# the sync itself), a file's mtime moves on a `cat >` copy that changed
|
|
10
|
+
# nothing, and rsync equalises directory mtimes on every run anyway. So we
|
|
11
|
+
# ask rsync, which has the answer already and knows its own matching rules
|
|
12
|
+
# better than any reimplementation would:
|
|
8
13
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# matters: editing a file in place leaves its directory's mtime untouched, and
|
|
13
|
-
# `rsync -a` equalises directory mtimes on every run anyway. A hand-edit on
|
|
14
|
-
# the target is therefore invisible to it.
|
|
14
|
+
# 1. What would a *forced* run transfer?
|
|
15
|
+
# One dry-run without --update. Empty means fully in sync — the common
|
|
16
|
+
# case and the cheap exit: one stat-walk per job and no more.
|
|
15
17
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
+
# 2. Of that, what does --update hold back?
|
|
19
|
+
# A second dry-run with --update. Everything the forced run would move
|
|
20
|
+
# but the normal one would not is exactly the set --update protects —
|
|
21
|
+
# files the target owns more recently.
|
|
18
22
|
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
# 2. Of those, which differ in content?
|
|
25
|
-
# Only these are worth asking about. A file that is merely newer — same
|
|
26
|
-
# bytes, later timestamp — is noise, and noise is what turns a prompt
|
|
27
|
-
# into a reflex. Sync both sides of a tree in either order and you get
|
|
28
|
-
# dozens of them.
|
|
29
|
-
#
|
|
30
|
-
# The first dry-run is also the cheap exit: when a forced run would move
|
|
31
|
-
# nothing, the job is fully in sync and the second run is skipped. That is the
|
|
32
|
-
# common case, so a quiet sync costs one extra stat-walk per job and no more.
|
|
23
|
+
# 3. What differs only in timestamp?
|
|
24
|
+
# rsync's itemize flags say so (">f..t" — time, same size). Those files
|
|
25
|
+
# are checksummed; identical content is noise, not drift, and noise is
|
|
26
|
+
# what turns a prompt into a reflex. Sync both sides of a tree in
|
|
27
|
+
# either order and you get dozens of them.
|
|
33
28
|
module Conflict
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
29
|
+
# The classified outcome of a forced dry-run for one job.
|
|
30
|
+
# pending — relative paths a sync would genuinely change
|
|
31
|
+
# time_only — same bytes, different timestamp; a sync merely aligns them
|
|
32
|
+
# conflicts — Entry list: target-side changes whose content differs
|
|
33
|
+
Drift = Struct.new(:pending, :time_only, :conflicts, keyword_init: true) do
|
|
34
|
+
def in_sync? = pending.empty? && conflicts.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# One file the target owns more recently than the source, with content
|
|
38
|
+
# that actually differs (or could not be checked — remote md5 failed —
|
|
39
|
+
# which is treated as differing, because guessing the other way would
|
|
40
|
+
# overwrite work). target_mtime is nil for remote targets.
|
|
38
41
|
Entry = Struct.new(:job, :rel, :source_path, :target_path,
|
|
39
42
|
:source_mtime, :target_mtime, keyword_init: true) do
|
|
40
43
|
def age_delta
|
|
@@ -43,55 +46,113 @@ module Twin
|
|
|
43
46
|
end
|
|
44
47
|
end
|
|
45
48
|
|
|
46
|
-
# rsync --itemize-changes line → relative path. Change lines
|
|
47
|
-
# update type and a file type (">f.st...... lib/foo.rb")
|
|
48
|
-
#
|
|
49
|
-
|
|
49
|
+
# rsync --itemize-changes line → [flags, relative path]. Change lines
|
|
50
|
+
# start with an update type and a file type (">f.st...... lib/foo.rb") or
|
|
51
|
+
# "*deleting"; attribute-only lines (leading ".") and the surrounding
|
|
52
|
+
# prose do not match — they describe no change a sync would make.
|
|
53
|
+
ENTRY_LINE = /\A(\*deleting|[<>ch][fdLDS]\S*)\s+(.+?)\s*\z/
|
|
50
54
|
|
|
51
55
|
module_function
|
|
52
56
|
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return
|
|
58
|
-
return
|
|
57
|
+
# Classify one job's drift by asking rsync. nil for render jobs (they
|
|
58
|
+
# compare content already and never use --update) and when a side is
|
|
59
|
+
# missing (status reports that on its own).
|
|
60
|
+
def drift(cfg, job)
|
|
61
|
+
return nil if job.render
|
|
62
|
+
return nil unless job.source_exists && job.target_exists
|
|
63
|
+
|
|
64
|
+
forced = itemized(Twin::Sync.rsync_args(cfg, job, dry_run: true, force: true))
|
|
65
|
+
return Drift.new(pending: [], time_only: [], conflicts: []) if forced.empty?
|
|
66
|
+
|
|
67
|
+
normal_rels = itemized(Twin::Sync.rsync_args(cfg, job, dry_run: true, force: false))
|
|
68
|
+
.map { |e| e[:rel] }.to_set
|
|
69
|
+
equal = equality_map(job, forced.filter_map { |e| e[:rel] if e[:kind] == :time })
|
|
59
70
|
|
|
60
|
-
|
|
71
|
+
assemble(forced, normal_rels, equal) do |rel|
|
|
72
|
+
conflict_entry(job, rel)
|
|
73
|
+
end
|
|
61
74
|
end
|
|
62
75
|
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
def
|
|
66
|
-
|
|
67
|
-
|
|
76
|
+
# Target-side changes worth asking about, in the order rsync reports them.
|
|
77
|
+
# Empty when --update holds nothing back, or holds back only identical files.
|
|
78
|
+
def detect(cfg, job)
|
|
79
|
+
drift(cfg, job)&.conflicts || []
|
|
80
|
+
end
|
|
68
81
|
|
|
69
|
-
|
|
70
|
-
|
|
82
|
+
# Pure assembly of a Drift from parsed entries. A file the normal run
|
|
83
|
+
# would also transfer flows source→target as intended; one only the
|
|
84
|
+
# forced run would touch is being held back by --update — the target owns
|
|
85
|
+
# it more recently. Content decides whether that is a conflict or noise.
|
|
86
|
+
def assemble(forced, normal_rels, equal)
|
|
87
|
+
d = Drift.new(pending: [], time_only: [], conflicts: [])
|
|
88
|
+
forced.each do |e|
|
|
89
|
+
rel = e[:rel]
|
|
90
|
+
case e[:kind]
|
|
91
|
+
when :deleted, :new then d.pending << rel
|
|
92
|
+
when :content
|
|
93
|
+
normal_rels.include?(rel) ? d.pending << rel : d.conflicts << yield(rel)
|
|
94
|
+
when :time
|
|
95
|
+
if equal[rel]
|
|
96
|
+
d.time_only << rel
|
|
97
|
+
elsif normal_rels.include?(rel)
|
|
98
|
+
d.pending << rel
|
|
99
|
+
else
|
|
100
|
+
d.conflicts << yield(rel)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
d
|
|
71
105
|
end
|
|
72
106
|
|
|
73
|
-
|
|
107
|
+
# Run rsync and parse its itemize output into [{rel:, kind:}, ...].
|
|
108
|
+
def itemized(args)
|
|
74
109
|
output, status = Twin::Sync.run(args)
|
|
75
110
|
return [] unless status.success?
|
|
76
111
|
output.lines.filter_map do |line|
|
|
77
|
-
m =
|
|
112
|
+
m = ENTRY_LINE.match(line)
|
|
78
113
|
next unless m
|
|
79
|
-
|
|
80
|
-
next if
|
|
81
|
-
rel
|
|
114
|
+
kind = classify(m[1])
|
|
115
|
+
next if kind == :attrs
|
|
116
|
+
{ rel: m[2], kind: kind }
|
|
82
117
|
end
|
|
83
118
|
end
|
|
84
119
|
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
120
|
+
# Itemize flags → what kind of change this is.
|
|
121
|
+
# :deleted — target-only file, removed by Delete: true
|
|
122
|
+
# :new — does not exist on the target yet (files and directories)
|
|
123
|
+
# :content — size differs, so the bytes certainly do
|
|
124
|
+
# :time — timestamp only; content equality still to be determined
|
|
125
|
+
# :attrs — permissions/owner, no change a sync-file cares about
|
|
126
|
+
def classify(flags)
|
|
127
|
+
return :deleted if flags == "*deleting"
|
|
128
|
+
body = flags[2..].to_s
|
|
129
|
+
return :new if body.include?("+")
|
|
130
|
+
return :content if body.include?("s")
|
|
131
|
+
return :time if body.include?("t") || body.include?("T")
|
|
132
|
+
:attrs
|
|
133
|
+
end
|
|
89
134
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
135
|
+
# Content equality for the :time candidates, {rel => bool}. Local pairs
|
|
136
|
+
# are compared directly; remote targets get one batched md5 round per job
|
|
137
|
+
# (STAT_SCRIPT-style), and an unanswered path counts as differing.
|
|
138
|
+
def equality_map(job, rels)
|
|
139
|
+
return {} if rels.empty?
|
|
140
|
+
if job.remote?
|
|
141
|
+
_host, rbase = Twin::Remote.split(job.target_path)
|
|
142
|
+
remote_paths = rels.to_h { |r| [r, job.directory ? File.join(rbase, r) : rbase] }
|
|
143
|
+
sums = Twin::Remote.md5_paths(Twin::Remote.split(job.target)[0], remote_paths.values) || {}
|
|
144
|
+
rels.to_h do |r|
|
|
145
|
+
local = local_md5(resolve(job.source_path, r))
|
|
146
|
+
[r, !local.nil? && sums[remote_paths[r]] == local]
|
|
147
|
+
end
|
|
148
|
+
else
|
|
149
|
+
rels.to_h { |r| [r, same_content?(resolve(job.source_path, r), resolve(job.target_path, r))] }
|
|
93
150
|
end
|
|
151
|
+
end
|
|
94
152
|
|
|
153
|
+
def conflict_entry(job, rel)
|
|
154
|
+
src = resolve(job.source_path, rel)
|
|
155
|
+
tgt = job.remote? ? job.target_path : resolve(job.target_path, rel)
|
|
95
156
|
Entry.new(
|
|
96
157
|
job: job, rel: rel, source_path: src, target_path: tgt,
|
|
97
158
|
source_mtime: mtime(src), target_mtime: job.remote? ? nil : mtime(tgt),
|
|
@@ -114,6 +175,12 @@ module Twin
|
|
|
114
175
|
|
|
115
176
|
def digest(path) = Digest::SHA256.file(path).hexdigest
|
|
116
177
|
|
|
178
|
+
def local_md5(path)
|
|
179
|
+
Digest::MD5.file(path).hexdigest
|
|
180
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
117
184
|
def mtime(path)
|
|
118
185
|
File.mtime(path)
|
|
119
186
|
rescue Errno::ENOENT, Errno::EACCES
|
data/lib/twin/picker.rb
CHANGED
|
@@ -14,6 +14,10 @@ module Twin
|
|
|
14
14
|
source_newer: "→",
|
|
15
15
|
target_newer: "←",
|
|
16
16
|
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.
|
|
20
|
+
unverified: "∘",
|
|
17
21
|
missing_target: "!",
|
|
18
22
|
missing_source: "!",
|
|
19
23
|
both_missing: "✗",
|
|
@@ -25,6 +29,7 @@ module Twin
|
|
|
25
29
|
source_newer: "\e[33m", # yellow
|
|
26
30
|
target_newer: "\e[36m", # cyan
|
|
27
31
|
in_sync: "\e[32m", # green
|
|
32
|
+
unverified: "\e[2m", # dim
|
|
28
33
|
missing_target: "\e[31m", # red
|
|
29
34
|
missing_source: "\e[31m", # red
|
|
30
35
|
both_missing: "\e[31m", # red
|
|
@@ -91,8 +96,7 @@ module Twin
|
|
|
91
96
|
|
|
92
97
|
rows = jobs.each_with_index.map do |j, i|
|
|
93
98
|
icon = STATUS_ICONS[j.status] || "?"
|
|
94
|
-
|
|
95
|
-
line = "#{icon} #{j.path.ljust(path_width)} #{delta}"
|
|
99
|
+
line = "#{icon} #{j.path.ljust(path_width)} #{job_delta(j)}"
|
|
96
100
|
"#{i}\t#{colorize(j.status, line)}"
|
|
97
101
|
end
|
|
98
102
|
|
|
@@ -186,14 +190,19 @@ module Twin
|
|
|
186
190
|
|
|
187
191
|
body = program.jobs.map do |j|
|
|
188
192
|
icon = STATUS_ICONS[j.status] || "?"
|
|
189
|
-
|
|
190
|
-
line = " #{icon} #{j.path.ljust(path_width)} #{delta}"
|
|
193
|
+
line = " #{icon} #{j.path.ljust(path_width)} #{job_delta(j)}"
|
|
191
194
|
colorize(j.status, line)
|
|
192
195
|
end
|
|
193
196
|
|
|
194
197
|
([header] + body).join("\n")
|
|
195
198
|
end
|
|
196
199
|
|
|
200
|
+
# A directory's mtime delta would mislead (it moves on every sync), so
|
|
201
|
+
# directory jobs show none.
|
|
202
|
+
def job_delta(job)
|
|
203
|
+
job.directory ? "" : format_delta(job.source_mtime, job.target_mtime)
|
|
204
|
+
end
|
|
205
|
+
|
|
197
206
|
def format_delta(sm, tm)
|
|
198
207
|
return "" if sm.nil? || tm.nil?
|
|
199
208
|
seconds = (sm - tm).to_i
|
data/lib/twin/remote.rb
CHANGED
|
@@ -26,22 +26,40 @@ module Twin
|
|
|
26
26
|
|
|
27
27
|
# Stat many paths in one ssh round-trip. Paths go over stdin (one per
|
|
28
28
|
# line), the remote loop answers "path<TAB>epoch" or "path<TAB>-" for
|
|
29
|
-
# missing ones. Tries BSD stat
|
|
29
|
+
# missing ones. Tries BSD stat, then GNU, then BusyBox `date -r` — covers
|
|
30
|
+
# macOS, Linux and OpenWrt. Auf dem Brume (25.08.2026) fehlte `stat`
|
|
31
|
+
# komplett: die leere Substitution wurde als Epoche 0 geparst und `twin
|
|
32
|
+
# status` zeigte 1970 statt "unbekannt". `date -r` kennt kein `--`;
|
|
33
|
+
# verkraftbar, weil hier nur absolute Zielpfade ankommen.
|
|
30
34
|
# Returns {path => Time or nil-if-missing}, or nil when ssh itself failed.
|
|
35
|
+
# POSIX sh, and it must stay free of single quotes: it is handed to the
|
|
36
|
+
# remote side wrapped in '...' so that *any* login shell passes it through
|
|
37
|
+
# literally. The usual POSIX escape for an embedded quote ('\'') is parsed
|
|
38
|
+
# differently by fish, so the rule here is simply not to need it — hence
|
|
39
|
+
# double quotes around the printf formats.
|
|
31
40
|
STAT_SCRIPT = <<~SH.freeze
|
|
32
41
|
while IFS= read -r p; do
|
|
33
42
|
if [ -e "$p" ]; then
|
|
34
|
-
|
|
43
|
+
m=$(stat -f %m -- "$p" 2>/dev/null || stat -c %Y -- "$p" 2>/dev/null || date -r "$p" +%s)
|
|
44
|
+
printf "%s\t%s\n" "$p" "$m"
|
|
35
45
|
else
|
|
36
|
-
printf
|
|
46
|
+
printf "%s\t-\n" "$p"
|
|
37
47
|
fi
|
|
38
48
|
done
|
|
39
49
|
SH
|
|
40
50
|
|
|
41
51
|
def stat_paths(host, paths)
|
|
42
52
|
return {} if paths.empty?
|
|
53
|
+
# Explicitly through /bin/sh. ssh hands the command to the *login shell*
|
|
54
|
+
# on the far side, and that is not necessarily POSIX: on a machine whose
|
|
55
|
+
# shell is fish, the bare script dies on "while IFS= read -r p; do" and
|
|
56
|
+
# twin read the failure as "host not answering" — every ssh target showed
|
|
57
|
+
# as :unreachable in status and the picker, for as long as SSH targets
|
|
58
|
+
# existed (#8, 2026-07-17). Found and fixed 2026-08-25.
|
|
59
|
+
raise "STAT_SCRIPT must not contain single quotes" if STAT_SCRIPT.include?("'")
|
|
60
|
+
|
|
43
61
|
out, _err, status = Open3.capture3(
|
|
44
|
-
"ssh", *SSH_OPTS, host, STAT_SCRIPT,
|
|
62
|
+
"ssh", *SSH_OPTS, host, "/bin/sh -c '#{STAT_SCRIPT}'",
|
|
45
63
|
stdin_data: paths.join("\n") + "\n"
|
|
46
64
|
)
|
|
47
65
|
return nil unless status.success?
|
|
@@ -50,13 +68,90 @@ module Twin
|
|
|
50
68
|
out.each_line do |line|
|
|
51
69
|
path, mtime = line.chomp.split("\t", 2)
|
|
52
70
|
next unless path && mtime
|
|
53
|
-
|
|
71
|
+
# Nur echte Epochen als Zeit werten. Scheitert die ganze stat-Kette,
|
|
72
|
+
# ist das Feld leer — das ist "unbekannt", nicht 1970.
|
|
73
|
+
result[path] = mtime.match?(/\A\d+\z/) ? Time.at(mtime.to_i) : nil
|
|
54
74
|
end
|
|
55
75
|
result
|
|
56
76
|
rescue Errno::ENOENT
|
|
57
77
|
nil # ssh not installed
|
|
58
78
|
end
|
|
59
79
|
|
|
80
|
+
# Checksum many paths in one ssh round-trip, same shape as stat_paths:
|
|
81
|
+
# paths over stdin, "path<TAB>md5" back, "-" for anything that is not a
|
|
82
|
+
# regular file. Tries BSD md5 first, then md5sum (GNU, BusyBox) — covers
|
|
83
|
+
# macOS, Linux and OpenWrt. md5sum prints "hash path"; the parameter
|
|
84
|
+
# expansion keeps only the first word. Same single-quote rule as
|
|
85
|
+
# STAT_SCRIPT, and MD5 is drift detection here, not cryptography.
|
|
86
|
+
# Returns {path => hex or nil-if-unreadable}, or nil when ssh itself failed.
|
|
87
|
+
MD5_SCRIPT = <<~SH.freeze
|
|
88
|
+
while IFS= read -r p; do
|
|
89
|
+
if [ -f "$p" ]; then
|
|
90
|
+
m=$(md5 -q "$p" 2>/dev/null || md5sum "$p" 2>/dev/null)
|
|
91
|
+
m=${m%% *}
|
|
92
|
+
printf "%s\t%s\n" "$p" "$m"
|
|
93
|
+
else
|
|
94
|
+
printf "%s\t-\n" "$p"
|
|
95
|
+
fi
|
|
96
|
+
done
|
|
97
|
+
SH
|
|
98
|
+
|
|
99
|
+
def md5_paths(host, paths)
|
|
100
|
+
return {} if paths.empty?
|
|
101
|
+
raise "MD5_SCRIPT must not contain single quotes" if MD5_SCRIPT.include?("'")
|
|
102
|
+
|
|
103
|
+
out, _err, status = Open3.capture3(
|
|
104
|
+
"ssh", *SSH_OPTS, host, "/bin/sh -c '#{MD5_SCRIPT}'",
|
|
105
|
+
stdin_data: paths.join("\n") + "\n"
|
|
106
|
+
)
|
|
107
|
+
return nil unless status.success?
|
|
108
|
+
|
|
109
|
+
result = {}
|
|
110
|
+
out.each_line do |line|
|
|
111
|
+
path, sum = line.chomp.split("\t", 2)
|
|
112
|
+
next unless path && sum
|
|
113
|
+
result[path] = sum.match?(/\A\h{32}\z/) ? sum.downcase : nil
|
|
114
|
+
end
|
|
115
|
+
result
|
|
116
|
+
rescue Errno::ENOENT
|
|
117
|
+
nil # ssh not installed
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Which of the tools twin relies on exist on the far side? One ssh
|
|
121
|
+
# round-trip per host, for `twin doctor`. rsync carries the sync itself;
|
|
122
|
+
# stat/date feed the batched mtime round; md5/md5sum feed the content
|
|
123
|
+
# check. Same single-quote rule as the other batch scripts.
|
|
124
|
+
# Returns {tool => present?}, or nil when ssh itself failed.
|
|
125
|
+
PREFLIGHT_SCRIPT = <<~SH.freeze
|
|
126
|
+
for t in rsync stat date md5 md5sum; do
|
|
127
|
+
if command -v "$t" >/dev/null 2>&1; then
|
|
128
|
+
printf "%s\tok\n" "$t"
|
|
129
|
+
else
|
|
130
|
+
printf "%s\t-\n" "$t"
|
|
131
|
+
fi
|
|
132
|
+
done
|
|
133
|
+
SH
|
|
134
|
+
|
|
135
|
+
def preflight(host)
|
|
136
|
+
raise "PREFLIGHT_SCRIPT must not contain single quotes" if PREFLIGHT_SCRIPT.include?("'")
|
|
137
|
+
|
|
138
|
+
out, _err, status = Open3.capture3("ssh", *SSH_OPTS, host, "/bin/sh -c '#{PREFLIGHT_SCRIPT}'")
|
|
139
|
+
return nil unless status.success?
|
|
140
|
+
parse_preflight(out)
|
|
141
|
+
rescue Errno::ENOENT
|
|
142
|
+
nil # ssh not installed
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def parse_preflight(out)
|
|
146
|
+
result = {}
|
|
147
|
+
out.each_line do |line|
|
|
148
|
+
tool, state = line.chomp.split("\t", 2)
|
|
149
|
+
next unless tool && state
|
|
150
|
+
result[tool] = state == "ok"
|
|
151
|
+
end
|
|
152
|
+
result
|
|
153
|
+
end
|
|
154
|
+
|
|
60
155
|
# Create a directory on the remote side (mkdir -p equivalent).
|
|
61
156
|
def mkdir_p(host, dir)
|
|
62
157
|
_out, _err, status = Open3.capture3("ssh", *SSH_OPTS, host, "mkdir", "-p", shellesc(dir))
|
data/lib/twin/scanner.rb
CHANGED
|
@@ -2,6 +2,7 @@ require "json"
|
|
|
2
2
|
require "open3"
|
|
3
3
|
|
|
4
4
|
require_relative "remote"
|
|
5
|
+
require_relative "conflict"
|
|
5
6
|
|
|
6
7
|
module Twin
|
|
7
8
|
# One YAML block from a sync-file, enriched with live filesystem state.
|
|
@@ -9,7 +10,7 @@ module Twin
|
|
|
9
10
|
:program, :path, :description, :active, :excludes, :owned, :label,
|
|
10
11
|
:source, :target, :cmd, :delete, :render, :render_outdated, :target_path_field, :sync_file,
|
|
11
12
|
:source_exists, :target_exists, :source_mtime, :target_mtime, :conflict,
|
|
12
|
-
:target_unreachable,
|
|
13
|
+
:target_unreachable, :directory, :content_equal, :drift,
|
|
13
14
|
keyword_init: true,
|
|
14
15
|
) do
|
|
15
16
|
def source_path = File.join(source, path)
|
|
@@ -29,6 +30,19 @@ module Twin
|
|
|
29
30
|
# Render jobs compare by content, not mtime — a rendered target's mtime
|
|
30
31
|
# bears no relation to the template's.
|
|
31
32
|
return render_outdated ? :source_newer : :in_sync if render
|
|
33
|
+
# Directory jobs carry no mtime verdict at all: a directory's mtime
|
|
34
|
+
# records the last entry added or removed — after a sync that is the
|
|
35
|
+
# sync itself, and even an *excluded* file moves it. `twin status`
|
|
36
|
+
# fills in `drift` by asking rsync; without it the honest answer is
|
|
37
|
+
# "not checked", not a guess.
|
|
38
|
+
if directory
|
|
39
|
+
return :unverified unless drift
|
|
40
|
+
return :target_newer if drift.conflicts.any?
|
|
41
|
+
return :source_newer if drift.pending.any?
|
|
42
|
+
return :in_sync
|
|
43
|
+
end
|
|
44
|
+
# Same bytes under a newer timestamp (a `cat >` copy) is not drift.
|
|
45
|
+
return :in_sync if content_equal
|
|
32
46
|
return :target_newer if conflict
|
|
33
47
|
return :in_sync if source_mtime.nil? || target_mtime.nil?
|
|
34
48
|
delta = source_mtime - target_mtime
|
|
@@ -50,7 +64,7 @@ module Twin
|
|
|
50
64
|
# Aggregate status across jobs — worst first.
|
|
51
65
|
def status
|
|
52
66
|
states = jobs.map(&:status)
|
|
53
|
-
%i[unreachable both_missing missing_source missing_target target_newer source_newer disabled in_sync]
|
|
67
|
+
%i[unreachable both_missing missing_source missing_target target_newer source_newer unverified disabled in_sync]
|
|
54
68
|
.find { |s| states.include?(s) } || :in_sync
|
|
55
69
|
end
|
|
56
70
|
|
|
@@ -103,9 +117,31 @@ module Twin
|
|
|
103
117
|
mtime = stats[rpath]
|
|
104
118
|
j.target_exists = !mtime.nil?
|
|
105
119
|
j.target_mtime = mtime
|
|
106
|
-
j.conflict = j.source_exists && mtime && j.source_mtime &&
|
|
120
|
+
j.conflict = !j.directory && j.source_exists && mtime && j.source_mtime &&
|
|
107
121
|
mtime - j.source_mtime >= 60
|
|
108
122
|
end
|
|
123
|
+
verify_remote_file_content(host, host_jobs)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Remote counterpart of the local content check in build_job: file jobs
|
|
128
|
+
# whose mtimes drifted get one batched md5 round per host. Identical
|
|
129
|
+
# content clears the conflict — the timestamps merely disagree.
|
|
130
|
+
def verify_remote_file_content(host, host_jobs)
|
|
131
|
+
candidates = host_jobs.select do |j|
|
|
132
|
+
!j.directory && !j.render && j.source_exists && j.target_exists &&
|
|
133
|
+
j.source_mtime && j.target_mtime && (j.target_mtime - j.source_mtime).abs >= 60
|
|
134
|
+
end
|
|
135
|
+
return if candidates.empty?
|
|
136
|
+
|
|
137
|
+
sums = Twin::Remote.md5_paths(host, candidates.map { |j| Twin::Remote.split(j.target_path).last })
|
|
138
|
+
return if sums.nil?
|
|
139
|
+
|
|
140
|
+
candidates.each do |j|
|
|
141
|
+
remote_sum = sums[Twin::Remote.split(j.target_path).last]
|
|
142
|
+
next if remote_sum.nil?
|
|
143
|
+
j.content_equal = remote_sum == Twin::Conflict.local_md5(j.source_path)
|
|
144
|
+
j.conflict = false if j.content_equal
|
|
109
145
|
end
|
|
110
146
|
end
|
|
111
147
|
|
|
@@ -170,8 +206,22 @@ module Twin
|
|
|
170
206
|
# target). conflict stays false so the mtime conflict-warning skips them.
|
|
171
207
|
render_outdated = render ? render_outdated?(src_full, tgt_full, vars, path) : nil
|
|
172
208
|
|
|
209
|
+
# rsync mirrors directories, so a directory source means a directory
|
|
210
|
+
# target — also for remote jobs, whose far side can't be inspected here.
|
|
211
|
+
directory = src_exists && File.directory?(src_full)
|
|
212
|
+
|
|
213
|
+
# A file job whose mtimes drifted apart may still hold the same bytes
|
|
214
|
+
# (a `cat >` copy before the first twin run). Check before judging;
|
|
215
|
+
# a directory's own mtime is judged not at all (see Job#status).
|
|
216
|
+
content_equal = nil
|
|
217
|
+
if !render && !remote && !directory && src_exists && tgt_exists &&
|
|
218
|
+
src_mtime && tgt_mtime && (tgt_mtime - src_mtime).abs >= 60
|
|
219
|
+
content_equal = Twin::Conflict.same_content?(src_full, tgt_full)
|
|
220
|
+
end
|
|
221
|
+
|
|
173
222
|
# Same 60s tolerance as Job#status, so mtime jitter never flags a conflict.
|
|
174
|
-
conflict = !render &&
|
|
223
|
+
conflict = !render && !directory && !content_equal &&
|
|
224
|
+
src_exists && tgt_exists && tgt_mtime && src_mtime &&
|
|
175
225
|
tgt_mtime - src_mtime >= 60
|
|
176
226
|
|
|
177
227
|
Job.new(
|
|
@@ -196,6 +246,8 @@ module Twin
|
|
|
196
246
|
target_mtime: tgt_mtime,
|
|
197
247
|
conflict: !!conflict,
|
|
198
248
|
target_unreachable: false,
|
|
249
|
+
directory: directory,
|
|
250
|
+
content_equal: content_equal,
|
|
199
251
|
)
|
|
200
252
|
end
|
|
201
253
|
|
data/lib/twin/sync.rb
CHANGED
|
@@ -130,6 +130,9 @@ module Twin
|
|
|
130
130
|
|
|
131
131
|
xfr = !dry_run && transferred?(output)
|
|
132
132
|
|
|
133
|
+
# conflict is content-verified by the scanner and only ever set on file
|
|
134
|
+
# jobs — a directory's mtime says nothing, so directory jobs surface
|
|
135
|
+
# target-side changes through the pre-run prompt instead.
|
|
133
136
|
if job.conflict && !xfr && !dry_run && !force
|
|
134
137
|
output += "\nskipped: target is newer, source not synced"
|
|
135
138
|
end
|
data/lib/twin/version.rb
CHANGED