mark-twin 0.4.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07baefa770954e6be785cd591c727fb287b7a64dcf84d4c83eb3c747978da7ba
4
- data.tar.gz: d29597f8f7b20380b658bd9127c5a72f904f8baea2e3dd20756a7431317e53bd
3
+ metadata.gz: d54022c14550637480eed5ecea2c7cf5b391d75724559c5c25cdac8cef134526
4
+ data.tar.gz: c5fc90db5a080109b60f330f591cb62055412ca3cf59f7d68d89e156b6d17f85
5
5
  SHA512:
6
- metadata.gz: 43c56ce8ed70a445dd3dabf803e68d25651cafe7b4a04e90f4202680592ac447345ab0f55e0dbd09970004d553c84868ae6aec6a643f48cd71f5d2583e0c20a4
7
- data.tar.gz: 25043aaa4ddf2f30ed8b546f52e68dccd008493414a9c8447d44c2c36acc45d32e375977b0d8b927623d2e5d6e937f0b2b56ed28cb8be53c2eeedeea08c73871
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. `conflict` is the coarse mtime
62
- flag used by `status` only — conflict *resolution* ignores it and asks rsync
63
- directly, because a directory's mtime says nothing about edits inside it.
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 jobs derive status from content
67
- (`render_outdated`), not mtime; non-render jobs ignore those fields.
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
  [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](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
- `twin` shows you what differs and syncs what you choose. At its core, it's just
34
- `rsync`, but a year later the file still tells you *why* you did it that way.
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
- ![Stage 2 — Fish Shell paths with apex preview](https://raw.githubusercontent.com/rhsev/mark-twin/main/docs/stage_2_fish.png)
68
+ ![Stage 2 — a rendered LaunchAgent entry with apex preview](https://raw.githubusercontent.com/rhsev/mark-twin/main/docs/stage_2_livesync.png)
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 # listing with source/target mtimes
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, and targets
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 in a single ssh round-trip
269
- per host, and an unreachable host shows as `?` instead of failing the scan.
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` is still mtime-based and cannot see an in-place edit. It is the
365
- cheap overview; `twin sync` is what decides.
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, and sync targets
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
- puts " src #{src}"
172
- puts " dst #{tgt}"
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
- # Finding out which files on the target would be silently skipped by rsync's
7
- # --update, and whether that actually matters.
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
- # `Job#conflict` is no help here. It compares the mtime of the job's own path,
10
- # and for a directory job that is the directory's mtime which says nothing
11
- # about the files inside it. Worse, it is wrong in exactly the case that
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 syncthe common
16
+ # case and the cheap exit: one stat-walk per job and no more.
15
17
  #
16
- # So we ask rsync, which has the answer already and knows its own matching
17
- # rules better than any reimplementation would:
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
- # 1. Which files does --update hold back?
20
- # Dry-run twice, once with --update and once without. Everything the
21
- # second run would transfer but the first would not is exactly the set
22
- # --update protects.
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
- # One file the target owns more recently than the source, with content that
35
- # actually differs. `same_content` is nil when it could not be determined
36
- # (remote targets) treated as a conflict, because guessing in the other
37
- # direction would overwrite work.
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 start with an
47
- # update type and a file type (">f.st...... lib/foo.rb"); "*deleting" and
48
- # the surrounding prose do not match.
49
- ITEMIZE_LINE = /\A[<>ch][fdLDS]\S*\s+(.+?)\s*\z/
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
- # Real conflicts for one job, in the order rsync reports them.
54
- # Empty when --update holds nothing back, or holds back only identical files.
55
- def detect(cfg, job)
56
- # Render jobs compare by content already and never use --update.
57
- return [] if job.render
58
- return [] unless job.source_exists && job.target_exists
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
- held_back_paths(cfg, job).filter_map { |rel| entry_for(job, rel) }
71
+ assemble(forced, normal_rels, equal) do |rel|
72
+ conflict_entry(job, rel)
73
+ end
61
74
  end
62
75
 
63
- # Relative paths that --update would skip: (would transfer forced) minus
64
- # (would transfer normally).
65
- def held_back_paths(cfg, job)
66
- forced = itemized_paths(Twin::Sync.rsync_args(cfg, job, dry_run: true, force: true))
67
- return [] if forced.empty? # nothing to move at all — no need to ask rsync twice
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
- normal = itemized_paths(Twin::Sync.rsync_args(cfg, job, dry_run: true, force: false))
70
- forced - normal
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
- def itemized_paths(args)
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 = ITEMIZE_LINE.match(line)
112
+ m = ENTRY_LINE.match(line)
78
113
  next unless m
79
- rel = m[1]
80
- next if rel == "./" || rel.end_with?("/") # directories carry no content
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
- # Build an Entry unless source and target hold the same bytes.
86
- def entry_for(job, rel)
87
- src = resolve(job.source_path, rel)
88
- tgt = resolve(job.target_path, rel)
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
- # Remote targets can't be read here; report them rather than assume.
91
- unless job.remote?
92
- return nil if same_content?(src, tgt)
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
- delta = format_delta(j.source_mtime, j.target_mtime)
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
- delta = format_delta(j.source_mtime, j.target_mtime)
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 first, then GNU covers macOS and Linux.
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
- printf '%s\t%s\n' "$p" "$(stat -f %m -- "$p" 2>/dev/null || stat -c %Y -- "$p")"
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 '%s\t-\n' "$p"
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
- result[path] = mtime == "-" ? nil : Time.at(mtime.to_i)
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 && src_exists && tgt_exists && tgt_mtime && src_mtime &&
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
@@ -1,3 +1,3 @@
1
1
  module Twin
2
- VERSION = "0.4.2"
2
+ VERSION = "0.5.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.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ralf Hülsmann
@@ -24,19 +24,14 @@ dependencies:
24
24
  - !ruby/object:Gem::Version
25
25
  version: '5.0'
26
26
  description: |
27
- Keeping two machines' configuration aligned usually ends in a shell script
28
- nobody dares touch, because the reasons behind it were never written down.
27
+ twin helps keep two machines' configuration aligned.
29
28
 
30
- twin puts the reasons and the instructions in the same file. A sync-file is
31
- ordinary Markdown: the prose explains why a path is synced, and fenced YAML
32
- blocks say what to do. rsync does the copying.
29
+ A sync-file is just Markdown: the prose says why a path is synced, and
30
+ fenced YAML blocks define what to do. rsync does the copying.
33
31
 
34
- It knows the awkward parts. Paths the other machine owns stay untouched and
35
- are named as such, not buried among build artefacts. When both sides changed
36
- since the last sync, twin shows a diff and asks before overwriting anything,
37
- once for the whole group, so a half-applied deploy is not a state it can
38
- leave you in. Targets are a local path, a mounted volume, or user@host:/path
39
- over ssh, with no difference in how they are used.
32
+ When both sides changed since the last sync, twin shows a diff and asks
33
+ before overwriting. A target is a local path, a mounted volume, or
34
+ user@host:/path over ssh, and twin treats all three the same.
40
35
 
41
36
  Interactive selection runs through fzf with a rendered Markdown preview; the
42
37
  same sync-files drive scriptable status, dry-run and sync commands.